@skyhook-io/k8s-ui 1.18.2 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/charts/AreaChart.test.tsx +39 -1
  4. package/src/components/charts/AreaChart.tsx +32 -13
  5. package/src/components/charts/axis.test.ts +17 -1
  6. package/src/components/charts/axis.ts +13 -0
  7. package/src/components/charts/format.test.ts +159 -2
  8. package/src/components/charts/format.ts +118 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  11. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  12. package/src/components/compare/ResourceCompareView.tsx +35 -12
  13. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  14. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  15. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  17. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  18. package/src/components/gitops/RollbackDialog.tsx +25 -6
  19. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  21. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  22. package/src/components/gitops/applicationset-row.test.ts +12 -0
  23. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  24. package/src/components/gitops/detail-helpers.test.ts +8 -2
  25. package/src/components/gitops/detail-helpers.ts +9 -11
  26. package/src/components/gitops/index.ts +1 -1
  27. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  28. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  29. package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
  30. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  31. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  32. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  33. package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
  34. package/src/components/gitops/insights/insights-helpers.ts +17 -3
  35. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  36. package/src/components/issues/IssueGuidance.tsx +48 -0
  37. package/src/components/issues/IssuesView.tsx +15 -3
  38. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  39. package/src/components/issues/diagnostic.ts +14 -1
  40. package/src/components/issues/index.ts +1 -0
  41. package/src/components/issues/severity.ts +15 -0
  42. package/src/components/issues/types.ts +10 -0
  43. package/src/components/kueue/AdmissionPath.tsx +223 -0
  44. package/src/components/kueue/QueueEvidence.tsx +148 -0
  45. package/src/components/kueue/constants.ts +1 -0
  46. package/src/components/kueue/eligibility.ts +43 -0
  47. package/src/components/kueue/flavors-tried.ts +79 -0
  48. package/src/components/kueue/helpers.test.ts +105 -0
  49. package/src/components/kueue/policy.ts +100 -0
  50. package/src/components/kueue/queue-detail.test.tsx +317 -0
  51. package/src/components/kueue/queue-status.ts +42 -0
  52. package/src/components/kueue/related-read.ts +48 -0
  53. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  54. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  55. package/src/components/logs/StructuredLogLine.tsx +8 -3
  56. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  57. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  58. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  59. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  60. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  61. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  62. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  63. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  64. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  65. package/src/components/resources/ResourcesView.tsx +50 -3
  66. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  67. package/src/components/resources/generic-status.ts +7 -0
  68. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  69. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  71. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  72. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  73. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  74. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  75. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  76. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  77. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  78. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  79. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  80. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  81. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  82. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  83. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  84. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  85. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  86. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  87. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  88. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  89. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  90. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  91. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  92. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  93. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  94. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  95. package/src/components/resources/renderers/index.ts +6 -0
  96. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  97. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  98. package/src/components/resources/resource-utils-argo.ts +84 -5
  99. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  100. package/src/components/resources/resource-utils-crd.ts +26 -0
  101. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  102. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  103. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  104. package/src/components/resources/resource-utils-kueue.ts +137 -4
  105. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  106. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  107. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  108. package/src/components/resources/resource-utils-ray.ts +38 -0
  109. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  110. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  111. package/src/components/resources/resource-utils.ts +45 -1
  112. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  113. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  114. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  115. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  116. package/src/components/topology/K8sResourceNode.tsx +18 -1
  117. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  118. package/src/components/topology/TopologyGraph.tsx +96 -94
  119. package/src/components/topology/layout.ts +3 -1
  120. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  121. package/src/components/ui/Badge.tsx +1 -0
  122. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  123. package/src/components/ui/drawer-components.tsx +1 -1
  124. package/src/components/ui/index.ts +1 -0
  125. package/src/components/workload/PodList.tsx +35 -9
  126. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  127. package/src/components/workload/WorkloadView.tsx +61 -8
  128. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  129. package/src/types/core.ts +41 -0
  130. package/src/types/gitops-insights.ts +19 -2
  131. package/src/types/gitops-tree.ts +2 -2
  132. package/src/types/kueue-queues.ts +84 -0
  133. package/src/types/read-coverage.ts +2 -1
  134. package/src/types/scheduling.ts +72 -0
  135. package/src/utils/animation.test.ts +38 -0
  136. package/src/utils/animation.ts +34 -0
  137. package/src/utils/api-resources-crd.test.ts +11 -0
  138. package/src/utils/api-resources.ts +5 -1
  139. package/src/utils/applications.test.ts +15 -1
  140. package/src/utils/applications.ts +26 -1
  141. package/src/utils/crd-schema.test.ts +107 -0
  142. package/src/utils/crd-schema.ts +201 -0
  143. package/src/utils/format.test.ts +37 -1
  144. package/src/utils/format.ts +28 -80
  145. package/src/utils/gitops-owner.test.ts +28 -2
  146. package/src/utils/gitops-owner.ts +19 -0
  147. package/src/utils/kueue-queue-workloads.ts +19 -0
  148. package/src/utils/resource-icons.ts +2 -0
  149. package/src/utils/topology-neighborhood.test.ts +90 -1
  150. package/src/utils/topology-neighborhood.ts +55 -3
  151. package/src/utils/workload-history-notes.test.ts +76 -0
  152. package/src/utils/workload-history-notes.ts +93 -0
@@ -1,4 +1,5 @@
1
1
  import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronUp, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
2
+ import { IssueGuidance } from '../../issues/IssueGuidance'
2
3
  import { PaneLoader } from '../../ui/PaneLoader'
3
4
  import { clsx } from 'clsx'
4
5
  import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
@@ -8,6 +9,8 @@ import { radarHealthNote } from '../health-provenance'
8
9
  import { SEVERITY_BADGE, SEVERITY_TEXT } from '../../../utils/badge-colors'
9
10
  import { formatRelativeAgeTime } from '../../../utils/format'
10
11
  import { Tooltip } from '../../ui/Tooltip'
12
+ import { RadarUpgradeNote, radarUpgradeNoteText } from '../../ui/RadarUpgradeNote'
13
+ import type { RadarUpgradeRequirement } from '../../../types/fetch-error'
11
14
  import { SearchBox } from '../../ui/SearchBox'
12
15
  import { FilterPill, type FilterPillTone } from '../../ui/FilterPill'
13
16
  import { Collapse, CollapseChevron } from '../../ui/Collapse'
@@ -21,6 +24,7 @@ import {
21
24
  entryTone,
22
25
  gitopsToSeverity,
23
26
  healthStatusRank,
27
+ historyRollbackBlockedReason,
24
28
  isArgoResourceSyncEligible,
25
29
  messageToPhase,
26
30
  missingResourceMessage,
@@ -359,7 +363,7 @@ export function GitOpsIssuesBand({
359
363
  <GitOpsHistoricalIssuesDisclosure operationFailure={operationFailure} others={others} onSelectIssue={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />
360
364
  ) : (
361
365
  <>
362
- {operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
366
+ {operationFailure && <GitOpsFailureCard issue={operationFailure} emptyRenderGuardShown={others[0]?.reason === 'AutoSyncBlockedEmpty'} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
363
367
  {others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
364
368
  </>
365
369
  )}
@@ -383,7 +387,7 @@ function GitOpsLifecycleBanner({ issue }: { issue: GitOpsIssue }) {
383
387
  </div>
384
388
  <p className="mt-1 text-[13px] text-theme-text-secondary">{issue.message}</p>
385
389
  {issue.cause && <p className="mt-1 text-[12px] text-orange-300/90">{issue.cause}</p>}
386
- {issue.action && <p className="mt-1 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
390
+ <IssueNextStep issue={issue} className="mt-1" />
387
391
  </div>
388
392
  </div>
389
393
  </div>
@@ -431,7 +435,7 @@ function GitOpsHistoricalIssuesDisclosure({
431
435
  </button>
432
436
  <Collapse open={expanded}>
433
437
  <div className="border-t border-theme-border">
434
- {operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
438
+ {operationFailure && <GitOpsFailureCard issue={operationFailure} emptyRenderGuardShown={others[0]?.reason === 'AutoSyncBlockedEmpty'} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
435
439
  {others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
436
440
  </div>
437
441
  </Collapse>
@@ -440,15 +444,18 @@ function GitOpsHistoricalIssuesDisclosure({
440
444
  }
441
445
 
442
446
  // Structured failure card. One unit, owns the failure narrative end-to-end:
443
- // title (parsed cause when recognized, falls back to raw reason), affected
444
- // resource, retry posture, raw controller error in a collapsed details.
447
+ // title (parsed cause when recognized, falls back to raw reason), sync
448
+ // hazard, cause, next step, affected resource, retry posture, raw controller
449
+ // error in a collapsed details.
445
450
  function GitOpsFailureCard({
446
451
  issue,
452
+ emptyRenderGuardShown,
447
453
  onSelect,
448
454
  onRemediate,
449
455
  remediationPending,
450
456
  }: {
451
457
  issue: GitOpsIssue
458
+ emptyRenderGuardShown: boolean
452
459
  onSelect?: (issue: GitOpsIssue) => void
453
460
  // onRemediate fires when the contextual fix button is clicked. The host
454
461
  // app (web/) dispatches on issue.remediation.kind to run the right
@@ -492,7 +499,16 @@ function GitOpsFailureCard({
492
499
  <span className="text-[11px] text-theme-text-tertiary">retried {issue.retryCount}×</span>
493
500
  )}
494
501
  </div>
502
+ {/* Ahead of the body: a cause can end in its own sync advice
503
+ ("so Argo creates it on sync"), which the hazard overrides. */}
504
+ {issue.hazard && (
505
+ <div className={clsx('mt-2 flex items-start gap-1.5 rounded border px-2 py-1.5 text-[12px] font-medium', SEVERITY_BADGE.warning)}>
506
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
507
+ <p className="min-w-0">{emptyRenderGuardShown ? 'See the empty-render guard below before syncing.' : issue.hazard}</p>
508
+ </div>
509
+ )}
495
510
  {body && <p className="mt-1 text-[13px] text-theme-text-secondary">{body}</p>}
511
+ <IssueNextStep issue={issue} className="mt-1" />
496
512
  {ref && (
497
513
  <dl className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]">
498
514
  <div className="flex gap-1.5">
@@ -591,6 +607,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
591
607
  const [expanded, setExpanded] = useState(false)
592
608
  if (issues.length === 0) return null
593
609
  const headline = issues[0]!
610
+ const wrapHeadline = headline.reason === 'RolledBack' || headline.reason === 'RollbackRevisionMismatch' || headline.reason === 'SyncWindowBlocked'
594
611
  const remaining = issues.length - 1
595
612
  const tone = severityTone(headline.severity)
596
613
  const headlineRef = headline.refs?.[0]
@@ -618,7 +635,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
618
635
  <div className="flex w-full items-center gap-2">
619
636
  {tone.icon}
620
637
  <span className={clsx('shrink-0 font-semibold', tone.text)}>{headline.reason}</span>
621
- <span className="min-w-0 flex-1 truncate text-theme-text-secondary">{headline.message}</span>
638
+ <span className={clsx('min-w-0 flex-1 text-theme-text-secondary', wrapHeadline ? 'whitespace-normal break-words' : 'truncate')}>{headline.message}</span>
622
639
  {/* Inline count when there are more issues behind the headline.
623
640
  Lightweight text — pairs with the chevron as a single disclosure
624
641
  unit instead of a separator-bordered count button. */}
@@ -637,10 +654,13 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
637
654
  )}
638
655
  {canExpand && <CollapseChevron open={expanded} className="h-3.5 w-3.5" />}
639
656
  </div>
640
- {headline.cause && (
641
- <p className="truncate pl-[22px] text-[11px] text-theme-text-tertiary">{headline.cause}</p>
642
- )}
643
657
  </button>
658
+ {(headline.cause || headline.action) && (
659
+ <div className="px-4 pb-2 pl-[38px]">
660
+ {headline.cause && <p className="text-[11px] text-theme-text-tertiary">{headline.cause}</p>}
661
+ <IssueNextStep issue={headline} />
662
+ </div>
663
+ )}
644
664
  {canExpand && (
645
665
  <Collapse open={expanded}>
646
666
  <div className="divide-y divide-theme-border border-t border-theme-border bg-theme-base/40">
@@ -650,33 +670,34 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
650
670
  const actionable = !!(onSelectIssue && ref)
651
671
  const rawMessage = issue.rawMessage && issue.rawMessage !== issue.message ? issue.rawMessage : ''
652
672
  return (
653
- <button
654
- key={`${issue.reason}-${index}`}
655
- type="button"
656
- onClick={() => actionable && onSelectIssue?.(issue)}
657
- disabled={!actionable}
658
- className={clsx(
659
- 'group flex w-full items-start gap-2 px-4 py-2 text-left text-xs transition-colors',
660
- actionable ? 'hover:bg-theme-hover/50' : 'cursor-default',
661
- )}
662
- >
663
- {t.icon}
664
- <div className="min-w-0 flex-1">
665
- <div className="flex items-center gap-2">
666
- <span className={clsx('font-semibold', t.text)}>{issue.reason}</span>
667
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{issue.scope}</span>
673
+ <div key={`${issue.reason}-${index}`}>
674
+ <button
675
+ type="button"
676
+ onClick={() => actionable && onSelectIssue?.(issue)}
677
+ disabled={!actionable}
678
+ className={clsx(
679
+ 'group flex w-full items-start gap-2 px-4 py-2 text-left text-xs transition-colors',
680
+ actionable ? 'hover:bg-theme-hover/50' : 'cursor-default',
681
+ )}
682
+ >
683
+ {t.icon}
684
+ <div className="min-w-0 flex-1">
685
+ <div className="flex items-center gap-2">
686
+ <span className={clsx('font-semibold', t.text)}>{issue.reason}</span>
687
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{issue.scope}</span>
688
+ </div>
689
+ <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
690
+ {issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
691
+ {rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
668
692
  </div>
669
- <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
670
- {issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
671
- {rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
672
- {issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
673
- </div>
674
- {actionable && ref && (
675
- <span className="shrink-0 self-center text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
676
- Open {refText(ref)} →
677
- </span>
678
- )}
679
- </button>
693
+ {actionable && ref && (
694
+ <span className="shrink-0 self-center text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
695
+ Open {refText(ref)} →
696
+ </span>
697
+ )}
698
+ </button>
699
+ <IssueNextStep issue={issue} className="px-4 pb-2 pl-[38px]" />
700
+ </div>
680
701
  )
681
702
  })}
682
703
  </div>
@@ -686,6 +707,13 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
686
707
  )
687
708
  }
688
709
 
710
+ // IssueNextStep is an issue's next step, styled alike on every surface of the
711
+ // band. Wrapped, never truncated: a next step often ends in the caveat that
712
+ // makes it safe (what not to sync), which truncation would cut.
713
+ function IssueNextStep({ issue, className }: { issue: GitOpsIssue; className?: string }) {
714
+ return <IssueGuidance action={issue.action} primary={issue.actionPrimary} secondaryTitle={issue.actionSecondaryTitle} secondary={issue.actionSecondary} ordered={issue.reason === 'SharedResourceWarning'} className={clsx('text-[11px] text-theme-text-tertiary', className)} />
715
+ }
716
+
689
717
  // firstSentence/remainderAfterFirstSentence split a string at the first
690
718
  // sentence boundary so the failure card can render "headline + body" from a
691
719
  // single source string. Falls back to the whole string when there's no
@@ -865,6 +893,7 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
865
893
  // honest about what it's NOT checking. Neutral note, not an alarm.
866
894
  const ignoredDiffs = insight.summary.ignoredDifferences
867
895
  const isArgoRoot = insight.summary.tool === 'argocd' && insight.summary.kind === 'Application'
896
+ const syncHazard = insight.issues?.find((issue) => issue.hazard)?.hazard
868
897
  // Full-diff affordance is offered only when the backend reports the Argo CD
869
898
  // integration is connected for this app AND the host wired the loader.
870
899
  const argoDiffAvailable = isArgoRoot && !!insight.capabilities?.argoDiffAvailable && !!renderResourceDiff
@@ -877,6 +906,7 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
877
906
  // to a muted placeholder and explain the cause once. Health stays — it's a
878
907
  // live signal that's still knowable and still worth reading.
879
908
  const syncUnavailable = isArgoRoot && (insight.summary.sync ?? '').toLowerCase() === 'unknown'
909
+ const rolledBack = !!insight.summary.rollbackRevision
880
910
  const toggleFacet = (facet: ResourceStatusFacet) => {
881
911
  setStatusFilters((prev) => {
882
912
  const next = new Set(prev)
@@ -1098,9 +1128,9 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
1098
1128
  const rowKey = refKey(change.ref)
1099
1129
  const focused = focusKey === rowKey
1100
1130
  const explanation = change.missing
1101
- ? missingResourceMessage(change.sync)
1131
+ ? missingResourceMessage(change.sync, { hazard: syncHazard, rolledBack })
1102
1132
  : !change.syncError && !change.message
1103
- ? explainChangeStatus(change.sync, change.health, insight.summary)
1133
+ ? explainChangeStatus(change.sync, change.health, insight.summary, { hazard: syncHazard, rolledBack })
1104
1134
  : ''
1105
1135
  const hasInlineDetail = !!(
1106
1136
  (change.drift && change.drift.entries.length > 0) ||
@@ -1275,7 +1305,12 @@ function explainChangeStatus(
1275
1305
  sync: string | undefined,
1276
1306
  health: string | undefined,
1277
1307
  summary: GitOpsInsight['summary'],
1308
+ context?: { hazard?: string; rolledBack?: boolean },
1278
1309
  ): string {
1310
+ if (context?.hazard && (sync === 'OutOfSync' || health === 'Missing')) {
1311
+ return 'See the sync warning above before syncing this Application.'
1312
+ }
1313
+ if (context?.rolledBack && health === 'Missing') return missingResourceMessage(sync, context)
1279
1314
  const isAuto = (summary.autoSyncMode ?? '').toLowerCase().startsWith('auto')
1280
1315
  const phase = (summary.operationPhase ?? '').toLowerCase()
1281
1316
  const inFlight = phase === 'running'
@@ -1287,6 +1322,7 @@ function explainChangeStatus(
1287
1322
  if (sync === 'OutOfSync') {
1288
1323
  if (parentFailed) return 'Sync failed for this resource — see the operation error above for the cause.'
1289
1324
  if (inFlight) return 'Live state differs from Git. A sync is in progress — wait for it to finish.'
1325
+ if (context?.rolledBack) return 'Live state differs from Git after a rollback. Sync applies the desired state and can undo the rollback.'
1290
1326
  if (isAuto) return 'Live state differs from Git. Auto-sync should reconcile this within a few minutes; click Sync to force it.'
1291
1327
  return 'Live state differs from Git. Click Sync to apply the desired state.'
1292
1328
  }
@@ -1606,12 +1642,17 @@ interface GitOpsActivityInsightViewProps {
1606
1642
  // with the target entry. The consumer is responsible for the confirmation
1607
1643
  // dialog + the actual mutation.
1608
1644
  onRollback?: (item: GitOpsHistoryItem) => void
1645
+ // Set when the connected Radar is too old to roll back: every Rollback
1646
+ // renders disabled, with the upgrade note above the history.
1647
+ rollbackUpgrade?: RadarUpgradeRequirement | null
1648
+ rollbackBlockedReason?: string
1609
1649
  }
1610
1650
 
1611
- export function GitOpsActivityInsightView({ insight, error, onRollback }: GitOpsActivityInsightViewProps) {
1651
+ export function GitOpsActivityInsightView({ insight, error, onRollback, rollbackUpgrade, rollbackBlockedReason }: GitOpsActivityInsightViewProps) {
1612
1652
  if (error && !insight) return <InsightErrorState error={error} />
1613
1653
  if (!insight) return <PaneLoader label="Loading GitOps activity…" className="h-full" />
1614
1654
  const canRollback = !!insight.capabilities?.rollback && !!onRollback
1655
+ const upgrade = canRollback ? rollbackUpgrade ?? null : null
1615
1656
  // Auto-sync makes rollback futile — the controller would re-sync to HEAD
1616
1657
  // immediately. Argo's own Web UI disables the button in this state. Detect
1617
1658
  // by autoSyncMode prefix so "Auto · prune", "Auto · self-heal", etc. all match.
@@ -1625,12 +1666,18 @@ export function GitOpsActivityInsightView({ insight, error, onRollback }: GitOps
1625
1666
  <SectionHeader
1626
1667
  icon={Clock3}
1627
1668
  title="History"
1628
- hint={canRollback ? 'Each revision can be rolled back to.' : undefined}
1669
+ hint={canRollback && !upgrade && !rollbackBlockedReason ? 'Each revision can be rolled back to.' : undefined}
1629
1670
  />
1671
+ {upgrade && <RadarUpgradeNote requirement={upgrade} className="border-b border-theme-border px-4 py-2.5" />}
1672
+ {canRollback && rollbackBlockedReason && <p className="border-b border-theme-border px-4 py-2.5 text-xs text-theme-text-secondary">{rollbackBlockedReason}</p>}
1630
1673
  <HistoryRows
1631
1674
  items={insight.history ?? []}
1632
1675
  canRollback={canRollback}
1633
- rollbackBlockedReason={autoSyncBlocksRollback ? 'Auto-sync is enabled. Disable it to enable rollback — otherwise the controller will sync forward to HEAD again.' : undefined}
1676
+ rollbackBlockedReason={rollbackBlockedReason || (upgrade
1677
+ ? radarUpgradeNoteText(upgrade)
1678
+ : autoSyncBlocksRollback
1679
+ ? 'Auto-sync is enabled. Disable it to enable rollback — otherwise the controller will sync forward to HEAD again.'
1680
+ : undefined)}
1634
1681
  onRollback={onRollback}
1635
1682
  />
1636
1683
  </section>
@@ -1670,6 +1717,7 @@ function HistoryRows({
1670
1717
  // the in-flight current operation row has no ID and rolling "back"
1671
1718
  // to it is meaningless.
1672
1719
  const showRollback = canRollback && !!item.id && !!onRollback
1720
+ const blockedReason = historyRollbackBlockedReason(item, rollbackBlockedReason)
1673
1721
  return (
1674
1722
  // `group` enables the Rollback button's hover-reveal; baseline
1675
1723
  // opacity-40 keeps it touch-discoverable.
@@ -1690,22 +1738,22 @@ function HistoryRows({
1690
1738
  )}
1691
1739
  {showRollback && (
1692
1740
  <Tooltip
1693
- content={rollbackBlockedReason || `Roll back to revision ${item.revision || `#${item.id}`}`}
1741
+ content={blockedReason || `Roll back to revision ${item.revision || `#${item.id}`}`}
1694
1742
  delay={200}
1695
1743
  wrapperClassName="ml-auto"
1696
1744
  >
1697
1745
  {/* Two visually distinct states:
1698
1746
  - Enabled: low-emphasis baseline (opacity-40), brightens on row hover
1699
- - Disabled (auto-sync on): full opacity but desaturated, cursor-not-allowed,
1747
+ - Disabled (auto-sync on, or an entry with no source): full opacity but desaturated, cursor-not-allowed,
1700
1748
  no hover affordance — reads unambiguously as "not actionable, here's why" */}
1701
1749
  <button
1702
1750
  type="button"
1703
- onClick={() => !rollbackBlockedReason && onRollback?.(item)}
1704
- disabled={!!rollbackBlockedReason}
1705
- aria-disabled={!!rollbackBlockedReason}
1751
+ onClick={() => !blockedReason && onRollback?.(item)}
1752
+ disabled={!!blockedReason}
1753
+ aria-disabled={!!blockedReason}
1706
1754
  className={clsx(
1707
1755
  'rounded border px-1.5 py-0.5 text-[10px] transition-opacity',
1708
- rollbackBlockedReason
1756
+ blockedReason
1709
1757
  ? 'cursor-not-allowed border-theme-border bg-theme-base text-theme-text-tertiary'
1710
1758
  : 'border-theme-border bg-theme-elevated text-theme-text-secondary opacity-40 hover:bg-theme-hover hover:text-theme-text-primary hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100'
1711
1759
  )}
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { GitOpsIssuesBand } from './GitOpsInsightViews'
4
+ import type { GitOpsIssue } from '../../../types/gitops-insights'
5
+ import { SEVERITY_BADGE } from '../../../utils/badge-colors'
6
+
7
+ const guard: GitOpsIssue = {
8
+ severity: 'critical',
9
+ scope: 'condition',
10
+ reason: 'AutoSyncBlockedEmpty',
11
+ message: 'Auto-sync blocked: the render includes none of the managed resources',
12
+ rawMessage: 'Skipping sync attempt to [8088f4c]: auto-sync will wipe out all resources',
13
+ cause: 'Argo CD blocked auto-sync because the render includes none of the resources this Application manages.',
14
+ action: 'The render includes none of the resources this Application manages. A sync with Prune enabled could delete all of them. Prune=false blocks direct Argo pruning; Prune=confirm requires confirmation. Pruning a rendered Namespace deletes its contents, regardless of their Prune options.',
15
+ }
16
+
17
+ // The band's top row is the most severe issue. Its next step must be visible
18
+ // without expanding, or a lower row's "click Sync" is the only advice on screen.
19
+ describe('GitOpsIssuesBand headline', () => {
20
+ it('shows the top issue’s next step when it stands alone', () => {
21
+ const html = renderToString(<GitOpsIssuesBand issues={[guard]} />)
22
+ expect(html).toContain(guard.action)
23
+ })
24
+
25
+ it('shows the top issue’s next step above a collapsed stack', () => {
26
+ const drift: GitOpsIssue = {
27
+ severity: 'warning',
28
+ scope: 'operation',
29
+ reason: 'OutOfSync',
30
+ message: 'Application is OutOfSync.',
31
+ action: 'Review the drift.',
32
+ }
33
+ const html = renderToString(<GitOpsIssuesBand issues={[guard, drift]} />)
34
+ expect(html).toContain(guard.action)
35
+ expect(html.indexOf(guard.action!)).toBeLessThan(html.indexOf(drift.action!))
36
+ })
37
+
38
+ it('renders no next-step line when the top issue has none', () => {
39
+ const html = renderToString(
40
+ <GitOpsIssuesBand issues={[{ severity: 'warning', scope: 'condition', reason: 'X', message: 'only a message' }]} />,
41
+ )
42
+ expect(html).toContain('only a message')
43
+ expect(html.match(/pl-\[22px\]/g)).toBeNull()
44
+ })
45
+ })
46
+
47
+ // Argo's namespace-not-found cause ends in its own sync advice.
48
+ const failedSync: GitOpsIssue = {
49
+ severity: 'critical',
50
+ scope: 'operation',
51
+ reason: 'Failed',
52
+ message: 'one or more objects failed to apply, reason: namespaces demo not found',
53
+ cause: 'The destination namespace does not exist. Create it, or enable CreateNamespace=true in the syncOptions so Argo creates it on sync.',
54
+ action: 'Open Activity for operation details.',
55
+ }
56
+ const causeAdvice = 'so Argo creates it on sync'
57
+ const emptyRenderHazard = 'The render includes none of the resources this Application manages. A sync with Prune enabled could delete all of them. Prune=false blocks direct Argo pruning; Prune=confirm requires confirmation. Pruning a rendered Namespace deletes its contents, regardless of their Prune options. Check the source path, targetRevision, Helm values, and Kustomize or directory include/exclude settings first.'
58
+ const emptyRenderReference = 'See the empty-render guard below before syncing.'
59
+
60
+ describe('GitOpsIssuesBand failure card', () => {
61
+ it('shows the failure’s next step after its cause', () => {
62
+ const html = renderToString(<GitOpsIssuesBand issues={[failedSync]} />)
63
+ expect(html).toContain(failedSync.action)
64
+ expect(html.indexOf(causeAdvice)).toBeLessThan(html.indexOf(failedSync.action!))
65
+ expect(html).not.toContain(SEVERITY_BADGE.warning)
66
+ })
67
+
68
+ it('warns of a sync hazard before the cause’s own sync advice', () => {
69
+ const hazard = emptyRenderHazard
70
+ const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, hazard }]} />)
71
+ const at = html.indexOf(hazard)
72
+ expect(at).toBeGreaterThan(-1)
73
+ expect(at).toBeLessThan(html.indexOf(causeAdvice))
74
+ expect(html.lastIndexOf(SEVERITY_BADGE.warning, at)).toBeGreaterThan(-1)
75
+ expect(html).toContain(failedSync.action)
76
+ expect(html).not.toContain(emptyRenderReference)
77
+ })
78
+
79
+ it.each([false, true])('points to the empty-render guard in the issue band (terminating=%s)', (terminating) => {
80
+ const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, hazard: emptyRenderHazard }, guard]} terminating={terminating} />)
81
+ expect(html).toContain(emptyRenderReference)
82
+ expect(html).not.toContain(emptyRenderHazard)
83
+ expect(html).toContain(guard.message)
84
+ expect(html).toContain(guard.action)
85
+ expect(html.indexOf(emptyRenderReference)).toBeLessThan(html.indexOf(causeAdvice))
86
+ })
87
+
88
+ it('keeps the full hazard when the guard is behind a collapsed row', () => {
89
+ const comparisonError: GitOpsIssue = { severity: 'critical', scope: 'condition', reason: 'ComparisonError', message: 'Cannot render source.' }
90
+ const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, hazard: emptyRenderHazard }, comparisonError, guard]} />)
91
+ expect(html).toContain(emptyRenderHazard)
92
+ expect(html).not.toContain(emptyRenderReference)
93
+ })
94
+
95
+ it('keeps shared-ownership hazards unchanged', () => {
96
+ const hazard = 'Resolve shared ownership before syncing: some resources are also tracked by Application payments. A successful sync can take over their tracking unless it uses FailOnSharedResource=true. While Application payments still declares them, its next successful sync can take tracking back.'
97
+ const shared: GitOpsIssue = { severity: 'warning', scope: 'condition', reason: 'SharedResourceWarning', message: 'Shared resource' }
98
+ const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, hazard }, shared]} />)
99
+ expect(html).toContain(hazard)
100
+ expect(html).not.toContain(emptyRenderReference)
101
+ })
102
+
103
+ it('shows a remote failure’s destination-cluster instruction', () => {
104
+ const action = 'Create namespace demo on the destination cluster, or add the CreateNamespace=true sync option.'
105
+ const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, action }]} />)
106
+ expect(html).toContain(action)
107
+ })
108
+ })
@@ -0,0 +1,73 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { GitOpsInsight } from '../../../types'
4
+ import { GitOpsChangesView, GitOpsIssuesBand } from './GitOpsInsightViews'
5
+
6
+ function insight(rolledBack: boolean): GitOpsInsight {
7
+ return {
8
+ summary: { tool: 'argocd', kind: 'Application', namespace: 'argocd', name: 'demo', autoSyncMode: 'Manual', operationPhase: 'Succeeded', rollbackRevision: rolledBack ? 'a'.repeat(40) : undefined },
9
+ issues: rolledBack ? [{
10
+ reason: 'RolledBack', scope: 'operation', severity: 'info',
11
+ message: `Last rollback deployed ${'a'.repeat(40)} by alice.`,
12
+ cause: "Auto-sync is off, so Argo won't deploy a new revision on its own. Changes made since the rollback can also show as drift. Syncing or enabling auto-sync deploys the current revision and can undo the rollback.",
13
+ }] : [],
14
+ changes: [{
15
+ ref: { group: 'apps', kind: 'Deployment', namespace: 'default', name: 'demo' },
16
+ sync: 'OutOfSync', category: 'OutOfSync', hasDesired: true, hasLive: true, partial: false,
17
+ }],
18
+ }
19
+ }
20
+
21
+ describe('rollback insights', () => {
22
+ it('puts the consequence on a separate line from the full revision', () => {
23
+ const html = renderToString(<GitOpsIssuesBand issues={insight(true).issues} />)
24
+ expect(html).toContain('a'.repeat(40))
25
+ expect(html).toMatch(/<span class="[^"]*whitespace-normal break-words">Last rollback deployed/)
26
+ expect(html).toMatch(/<p class="[^"]*">Auto-sync is off/)
27
+ expect(html).toContain('Changes made since the rollback can also show as drift.')
28
+ })
29
+
30
+ it('shows the mismatch revisions, compatibility explanation, and recovery advice in full', () => {
31
+ const message = `Last rollback deployed different sources or revisions than requested: ${'b'.repeat(40)} instead of ${'a'.repeat(40)}.`
32
+ const cause = "On Argo CD before 2.12, rolling back across a single-source/multi-source change or to a multi-source entry can deploy the wrong revisions or render nothing; with prune, it can delete resources."
33
+ const action = 'Check the Argo CD controller version, then sync the intended revision manually.'
34
+ const html = renderToString(<GitOpsIssuesBand issues={[{
35
+ reason: 'RollbackRevisionMismatch', scope: 'operation', severity: 'warning', message, cause, action,
36
+ }]} />)
37
+ expect(html).toMatch(/<span class="[^"]*whitespace-normal break-words">Last rollback deployed/)
38
+ expect(html).toContain(message)
39
+ expect(html).toMatch(/<p class="[^"]*">On Argo CD before 2.12/)
40
+ expect(html).toContain(action)
41
+ })
42
+
43
+ it('explains that resource Sync can undo the rollback', () => {
44
+ const html = renderToString(<GitOpsChangesView insight={insight(true)} />)
45
+ expect(html).toContain('Live state differs from Git after a rollback. Sync applies the desired state and can undo the rollback.')
46
+ expect(html).not.toContain('Click Sync to apply the desired state.')
47
+ })
48
+
49
+ it('keeps ordinary manual-drift resource copy unchanged', () => {
50
+ expect(renderToString(<GitOpsChangesView insight={insight(false)} />)).toContain('Live state differs from Git. Click Sync to apply the desired state.')
51
+ })
52
+ })
53
+
54
+ it('keeps Missing rows rollback-aware when sync is Unknown and no RolledBack issue exists', () => {
55
+ const data = insight(true)
56
+ data.summary.sync = 'Unknown'
57
+ data.issues = []
58
+ data.changes![0].sync = 'Unknown'
59
+ data.changes![0].missing = true
60
+ data.changes![0].hasLive = false
61
+ const html = renderToString(<GitOpsChangesView insight={data} />)
62
+ expect(html).toContain('Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.')
63
+ expect(html).not.toContain('Sync the application to create it.')
64
+ })
65
+
66
+ it('keeps Missing health explanations rollback-aware without a confirmed missing lookup', () => {
67
+ const data = insight(true)
68
+ data.changes![0].sync = 'Unknown'
69
+ data.changes![0].health = 'Missing'
70
+ const html = renderToString(<GitOpsChangesView insight={data} />)
71
+ expect(html).toContain('Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.')
72
+ expect(html).not.toContain('Declared in Git but not present in the cluster. Sync to create it.')
73
+ })
@@ -0,0 +1,29 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { GitOpsIssue } from '../../../types/gitops-insights'
4
+ import { GitOpsIssuesBand } from './GitOpsInsightViews'
5
+
6
+ const windowIssues: GitOpsIssue[] = [
7
+ {
8
+ reason: 'SyncWindowBlocked', scope: 'operation', severity: 'warning',
9
+ message: "A sync window is holding this sync. Argo CD continues it when the AppProject's sync windows allow syncing.",
10
+ action: 'Wait for the window, or terminate the operation to drop it. A terminated operation stays Terminating until a window allows syncing; it applies nothing more, and changes already applied stay.',
11
+ },
12
+ {
13
+ reason: 'SyncWindowBlocked', scope: 'operation', severity: 'warning',
14
+ message: 'This operation was terminated while a sync window held it. Argo CD completes the termination when a window allows syncing. It applies nothing more; changes already applied stay.',
15
+ action: "Wait for the window to open, or change the AppProject's sync windows.",
16
+ },
17
+ ]
18
+
19
+ describe('sync-window insights', () => {
20
+ it.each(windowIssues)('shows the full message and action for $message', (issue) => {
21
+ for (const issues of [[issue], [issue, { ...issue, reason: 'OtherIssue' }]]) {
22
+ const html = renderToString(<GitOpsIssuesBand issues={issues} />)
23
+ expect(html).toMatch(/<span class="[^"]*whitespace-normal break-words">/)
24
+ expect(html).not.toMatch(/<span class="[^"]*truncate">/)
25
+ expect(html).toContain(issue.message.replaceAll("'", '&#x27;'))
26
+ expect(html).toContain(`>${issue.action!.replaceAll("'", '&#x27;')}</p>`)
27
+ }
28
+ })
29
+ })
@@ -9,6 +9,7 @@ import {
9
9
  entryTone,
10
10
  gitopsToSeverity,
11
11
  healthStatusRank,
12
+ historyRollbackBlockedReason,
12
13
  isArgoResourceSyncEligible,
13
14
  messageToPhase,
14
15
  missingResourceMessage,
@@ -30,6 +31,49 @@ describe('confirmed missing resources', () => {
30
31
  expect(isArgoResourceSyncEligible(item, true)).toBe(true)
31
32
  })
32
33
 
34
+ it.each(['OutOfSync', 'Unknown', undefined])('defers to the hazard before rollback advice for %s', (sync) => {
35
+ expect(missingResourceMessage(sync, { hazard: 'Sync can delete resources.', rolledBack: true })).toBe('Not present in the cluster. See the sync warning above before syncing this Application.')
36
+ expect(missingResourceMessage(sync, { hazard: 'Sync can delete resources.' })).toBe('Not present in the cluster. See the sync warning above before syncing this Application.')
37
+ })
38
+
39
+ it('keeps refresh advice for Synced even with hazard and rollback context', () => {
40
+ expect(missingResourceMessage('Synced', { hazard: 'Sync can delete resources.', rolledBack: true })).toBe(missingResourceMessage('Synced'))
41
+ })
42
+
43
+ it('uses named rollback context and treats an empty hazard as absent', () => {
44
+ expect(missingResourceMessage('OutOfSync', { rolledBack: true })).toBe('Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.')
45
+ expect(missingResourceMessage('OutOfSync', { hazard: '', rolledBack: true })).toBe(missingResourceMessage('OutOfSync', { rolledBack: true }))
46
+ expect(missingResourceMessage('OutOfSync', { rolledBack: false })).toBe(missingResourceMessage('OutOfSync'))
47
+ })
48
+
49
+ it('defers creation advice to the sync hazard while preserving stale Synced refresh advice', () => {
50
+ for (const sync of ['OutOfSync', 'Unknown', undefined]) {
51
+ expect(missingResourceMessage(sync, { hazard: 'Resolve shared ownership before syncing.' })).toBe(
52
+ 'Not present in the cluster. See the sync warning above before syncing this Application.',
53
+ )
54
+ }
55
+ expect(missingResourceMessage('Synced', { hazard: 'A pruning sync could delete everything.' })).toContain('Refresh')
56
+ })
57
+
58
+ it('warns that recreating a missing resource can undo a rollback', () => {
59
+ for (const sync of ['OutOfSync', 'Unknown', undefined]) {
60
+ expect(missingResourceMessage(sync, { rolledBack: true })).toBe(
61
+ 'Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.',
62
+ )
63
+ }
64
+ })
65
+
66
+ it('prioritizes the sync hazard over rollback advice', () => {
67
+ for (const sync of ['OutOfSync', 'Unknown', undefined]) {
68
+ expect(missingResourceMessage(sync, { hazard: 'Resolve shared tracking.', rolledBack: true })).toBe(
69
+ 'Not present in the cluster. See the sync warning above before syncing this Application.',
70
+ )
71
+ }
72
+ // Argo calling it Synced means the cluster view is stale: refresh, whatever the context.
73
+ expect(missingResourceMessage('Synced', { hazard: 'Resolve shared tracking.', rolledBack: true })).toContain('Refresh the application')
74
+ expect(missingResourceMessage('OutOfSync', {})).toContain('Sync the application')
75
+ })
76
+
33
77
  it('asks for a refresh when Argo still reports Synced', () => {
34
78
  expect(missingResourceMessage('Synced')).toContain('Refresh')
35
79
  expect(missingResourceMessage('Synced')).not.toContain('Sync the application')
@@ -215,3 +259,24 @@ describe('syncStatusRank / healthStatusRank', () => {
215
259
  expect(healthStatusRank('Degraded')).toBeLessThan(healthStatusRank('Healthy'))
216
260
  })
217
261
  })
262
+
263
+ describe('historyRollbackBlockedReason', () => {
264
+ const autoSync = 'Auto-sync is enabled.'
265
+ it('allows an entry the backend marks rollbackable', () => {
266
+ expect(historyRollbackBlockedReason({ id: '0', rollbackable: true })).toBeUndefined()
267
+ })
268
+ it('blocks an entry that records no source', () => {
269
+ expect(historyRollbackBlockedReason({ id: '1', rollbackable: false })).toMatch(/records no source/)
270
+ })
271
+ // Turning auto-sync off would not make this entry rollbackable.
272
+ it('names the missing source over auto-sync', () => {
273
+ expect(historyRollbackBlockedReason({ id: '1', rollbackable: false }, autoSync)).toMatch(/records no source/)
274
+ })
275
+ it('passes the app-wide reason through for a rollbackable entry', () => {
276
+ expect(historyRollbackBlockedReason({ id: '0', rollbackable: true }, autoSync)).toBe(autoSync)
277
+ })
278
+ // A Radar that predates the field omits it; the rollback API decides.
279
+ it('leaves an entry without the field enabled', () => {
280
+ expect(historyRollbackBlockedReason({ id: '2' })).toBeUndefined()
281
+ })
282
+ })