@skyhook-io/k8s-ui 1.18.1 → 1.19.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 (101) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. package/vitest.config.ts +26 -0
@@ -41,6 +41,7 @@ import { toggleSet } from './GitOpsGraphFilterRail'
41
41
  import { useFilterState, defineFilterSchema } from '../../filter-state'
42
42
  import { parseContextName } from '../../utils/context-name'
43
43
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format'
44
+ import { FilterRailPanel } from '../shared/FilterRailPanel'
44
45
 
45
46
  // =============================================================================
46
47
  // GitOpsTableView — the canonical GitOps fleet list (Argo + Flux).
@@ -937,13 +938,17 @@ function GitOpsFilterSidebar({
937
938
  side === 'right' ? 'border-l max-[899px]:border-l-0' : 'border-r max-[899px]:border-r-0'
938
939
  }`}
939
940
  >
940
- <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
941
- <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
942
- <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
943
- Clear
944
- </button>
945
- </div>
946
- <div className="flex-1 overflow-y-auto">
941
+ <FilterRailPanel
942
+ activeCount={
943
+ syncFilters.size + healthFilters.size + automationFilters.size + kindFilters.size +
944
+ projectFilters.size + namespaceFilters.size + (lifecycleFilter === 'all' ? 0 : 1)
945
+ }
946
+ clear={
947
+ <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
948
+ Clear
949
+ </button>
950
+ }
951
+ >
947
952
  {loading ? (
948
953
  <BoardRailSkeleton
949
954
  sections={[
@@ -1062,7 +1067,7 @@ function GitOpsFilterSidebar({
1062
1067
  </GitOpsFilterSection>
1063
1068
  </>
1064
1069
  )}
1065
- </div>
1070
+ </FilterRailPanel>
1066
1071
  </aside>
1067
1072
  )
1068
1073
  }
@@ -113,6 +113,38 @@ describe('GitOpsChangesView expanded Argo row', () => {
113
113
  expect(html).not.toContain('Sync the application to create it')
114
114
  })
115
115
 
116
+ test.each([
117
+ ['ManualDrift', 'The render includes none of the resources this Application manages.', 'Manual'],
118
+ ['AutoSyncBlockedEmpty', 'A pruning sync could delete every listed resource.', 'Auto · prune'],
119
+ ['SharedResourceWarning', 'Resolve shared ownership before syncing.', 'Manual'],
120
+ ['SelfHealDisabled', 'Resolve shared ownership before syncing.', 'Auto'],
121
+ ['Failed', 'Fix the comparison error and refresh before syncing.', 'Manual'],
122
+ ])('defers resource sync/create advice to the %s hazard', (reason, hazard, autoSyncMode) => {
123
+ const data = insight(false)
124
+ data.summary.autoSyncMode = autoSyncMode
125
+ data.issues = [{ severity: 'warning', scope: 'operation', reason, message: 'Sync warning', hazard }]
126
+ data.changes = [
127
+ { ...change, drift: undefined },
128
+ { ...change, ref: { ...change.ref, name: 'missing' }, missing: true, hasLive: false, health: 'Missing', drift: undefined },
129
+ { ...change, ref: { ...change.ref, name: 'health-missing' }, sync: 'Unknown', health: 'Missing', drift: undefined },
130
+ ]
131
+ const html = renderToStaticMarkup(<GitOpsChangesView insight={data} />)
132
+ expect(count(html, 'See the sync warning above before syncing this Application.')).toBe(3)
133
+ expect(html).not.toContain('Click Sync')
134
+ expect(html).not.toContain('click Sync to force it')
135
+ expect(html).not.toContain('Auto-sync should reconcile')
136
+ expect(html).not.toContain('Sync the application to create it')
137
+ expect(html).not.toContain('Sync to create it')
138
+ })
139
+
140
+ test('keeps ordinary sync advice when there is no hazard', () => {
141
+ const data = insight(false)
142
+ data.summary.autoSyncMode = 'Manual'
143
+ const html = renderToStaticMarkup(<GitOpsChangesView insight={data} />)
144
+ expect(html).toContain('Click Sync to apply the desired state.')
145
+ expect(html).not.toContain('See the sync warning above')
146
+ })
147
+
116
148
  test("hands the last-applied list to the Argo diff instead of rendering it alongside", () => {
117
149
  let passed: GitOpsDrift | undefined
118
150
  const html = renderToStaticMarkup(
@@ -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'
@@ -359,7 +360,7 @@ export function GitOpsIssuesBand({
359
360
  <GitOpsHistoricalIssuesDisclosure operationFailure={operationFailure} others={others} onSelectIssue={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />
360
361
  ) : (
361
362
  <>
362
- {operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
363
+ {operationFailure && <GitOpsFailureCard issue={operationFailure} emptyRenderGuardShown={others[0]?.reason === 'AutoSyncBlockedEmpty'} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
363
364
  {others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
364
365
  </>
365
366
  )}
@@ -383,7 +384,7 @@ function GitOpsLifecycleBanner({ issue }: { issue: GitOpsIssue }) {
383
384
  </div>
384
385
  <p className="mt-1 text-[13px] text-theme-text-secondary">{issue.message}</p>
385
386
  {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>}
387
+ <IssueNextStep issue={issue} className="mt-1" />
387
388
  </div>
388
389
  </div>
389
390
  </div>
@@ -431,7 +432,7 @@ function GitOpsHistoricalIssuesDisclosure({
431
432
  </button>
432
433
  <Collapse open={expanded}>
433
434
  <div className="border-t border-theme-border">
434
- {operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
435
+ {operationFailure && <GitOpsFailureCard issue={operationFailure} emptyRenderGuardShown={others[0]?.reason === 'AutoSyncBlockedEmpty'} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
435
436
  {others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
436
437
  </div>
437
438
  </Collapse>
@@ -440,15 +441,18 @@ function GitOpsHistoricalIssuesDisclosure({
440
441
  }
441
442
 
442
443
  // 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.
444
+ // title (parsed cause when recognized, falls back to raw reason), sync
445
+ // hazard, cause, next step, affected resource, retry posture, raw controller
446
+ // error in a collapsed details.
445
447
  function GitOpsFailureCard({
446
448
  issue,
449
+ emptyRenderGuardShown,
447
450
  onSelect,
448
451
  onRemediate,
449
452
  remediationPending,
450
453
  }: {
451
454
  issue: GitOpsIssue
455
+ emptyRenderGuardShown: boolean
452
456
  onSelect?: (issue: GitOpsIssue) => void
453
457
  // onRemediate fires when the contextual fix button is clicked. The host
454
458
  // app (web/) dispatches on issue.remediation.kind to run the right
@@ -492,7 +496,16 @@ function GitOpsFailureCard({
492
496
  <span className="text-[11px] text-theme-text-tertiary">retried {issue.retryCount}×</span>
493
497
  )}
494
498
  </div>
499
+ {/* Ahead of the body: a cause can end in its own sync advice
500
+ ("so Argo creates it on sync"), which the hazard overrides. */}
501
+ {issue.hazard && (
502
+ <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)}>
503
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
504
+ <p className="min-w-0">{emptyRenderGuardShown ? 'See the empty-render guard below before syncing.' : issue.hazard}</p>
505
+ </div>
506
+ )}
495
507
  {body && <p className="mt-1 text-[13px] text-theme-text-secondary">{body}</p>}
508
+ <IssueNextStep issue={issue} className="mt-1" />
496
509
  {ref && (
497
510
  <dl className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]">
498
511
  <div className="flex gap-1.5">
@@ -637,10 +650,13 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
637
650
  )}
638
651
  {canExpand && <CollapseChevron open={expanded} className="h-3.5 w-3.5" />}
639
652
  </div>
640
- {headline.cause && (
641
- <p className="truncate pl-[22px] text-[11px] text-theme-text-tertiary">{headline.cause}</p>
642
- )}
643
653
  </button>
654
+ {(headline.cause || headline.action) && (
655
+ <div className="px-4 pb-2 pl-[38px]">
656
+ {headline.cause && <p className="text-[11px] text-theme-text-tertiary">{headline.cause}</p>}
657
+ <IssueNextStep issue={headline} />
658
+ </div>
659
+ )}
644
660
  {canExpand && (
645
661
  <Collapse open={expanded}>
646
662
  <div className="divide-y divide-theme-border border-t border-theme-border bg-theme-base/40">
@@ -650,33 +666,34 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
650
666
  const actionable = !!(onSelectIssue && ref)
651
667
  const rawMessage = issue.rawMessage && issue.rawMessage !== issue.message ? issue.rawMessage : ''
652
668
  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>
669
+ <div key={`${issue.reason}-${index}`}>
670
+ <button
671
+ type="button"
672
+ onClick={() => actionable && onSelectIssue?.(issue)}
673
+ disabled={!actionable}
674
+ className={clsx(
675
+ 'group flex w-full items-start gap-2 px-4 py-2 text-left text-xs transition-colors',
676
+ actionable ? 'hover:bg-theme-hover/50' : 'cursor-default',
677
+ )}
678
+ >
679
+ {t.icon}
680
+ <div className="min-w-0 flex-1">
681
+ <div className="flex items-center gap-2">
682
+ <span className={clsx('font-semibold', t.text)}>{issue.reason}</span>
683
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{issue.scope}</span>
684
+ </div>
685
+ <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
686
+ {issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
687
+ {rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
668
688
  </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>
689
+ {actionable && ref && (
690
+ <span className="shrink-0 self-center text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
691
+ Open {refText(ref)} →
692
+ </span>
693
+ )}
694
+ </button>
695
+ <IssueNextStep issue={issue} className="px-4 pb-2 pl-[38px]" />
696
+ </div>
680
697
  )
681
698
  })}
682
699
  </div>
@@ -686,6 +703,13 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
686
703
  )
687
704
  }
688
705
 
706
+ // IssueNextStep is an issue's next step, styled alike on every surface of the
707
+ // band. Wrapped, never truncated: a next step often ends in the caveat that
708
+ // makes it safe (what not to sync), which truncation would cut.
709
+ function IssueNextStep({ issue, className }: { issue: GitOpsIssue; className?: string }) {
710
+ 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)} />
711
+ }
712
+
689
713
  // firstSentence/remainderAfterFirstSentence split a string at the first
690
714
  // sentence boundary so the failure card can render "headline + body" from a
691
715
  // single source string. Falls back to the whole string when there's no
@@ -865,6 +889,7 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
865
889
  // honest about what it's NOT checking. Neutral note, not an alarm.
866
890
  const ignoredDiffs = insight.summary.ignoredDifferences
867
891
  const isArgoRoot = insight.summary.tool === 'argocd' && insight.summary.kind === 'Application'
892
+ const syncHazard = insight.issues?.find((issue) => issue.hazard)?.hazard
868
893
  // Full-diff affordance is offered only when the backend reports the Argo CD
869
894
  // integration is connected for this app AND the host wired the loader.
870
895
  const argoDiffAvailable = isArgoRoot && !!insight.capabilities?.argoDiffAvailable && !!renderResourceDiff
@@ -1098,9 +1123,9 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
1098
1123
  const rowKey = refKey(change.ref)
1099
1124
  const focused = focusKey === rowKey
1100
1125
  const explanation = change.missing
1101
- ? missingResourceMessage(change.sync)
1126
+ ? missingResourceMessage(change.sync, { hazard: syncHazard })
1102
1127
  : !change.syncError && !change.message
1103
- ? explainChangeStatus(change.sync, change.health, insight.summary)
1128
+ ? explainChangeStatus(change.sync, change.health, insight.summary, { hazard: syncHazard })
1104
1129
  : ''
1105
1130
  const hasInlineDetail = !!(
1106
1131
  (change.drift && change.drift.entries.length > 0) ||
@@ -1275,7 +1300,12 @@ function explainChangeStatus(
1275
1300
  sync: string | undefined,
1276
1301
  health: string | undefined,
1277
1302
  summary: GitOpsInsight['summary'],
1303
+ context?: { hazard?: string; rolledBack?: boolean },
1278
1304
  ): string {
1305
+ if (context?.hazard && (sync === 'OutOfSync' || health === 'Missing')) {
1306
+ return 'See the sync warning above before syncing this Application.'
1307
+ }
1308
+ if (context?.rolledBack && health === 'Missing') return missingResourceMessage(sync, context)
1279
1309
  const isAuto = (summary.autoSyncMode ?? '').toLowerCase().startsWith('auto')
1280
1310
  const phase = (summary.operationPhase ?? '').toLowerCase()
1281
1311
  const inFlight = phase === 'running'
@@ -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
+ })
@@ -30,6 +30,34 @@ describe('confirmed missing resources', () => {
30
30
  expect(isArgoResourceSyncEligible(item, true)).toBe(true)
31
31
  })
32
32
 
33
+ it('defers creation advice to the sync hazard while preserving stale Synced refresh advice', () => {
34
+ for (const sync of ['OutOfSync', 'Unknown', undefined]) {
35
+ expect(missingResourceMessage(sync, { hazard: 'Resolve shared ownership before syncing.' })).toBe(
36
+ 'Not present in the cluster. See the sync warning above before syncing this Application.',
37
+ )
38
+ }
39
+ expect(missingResourceMessage('Synced', { hazard: 'A pruning sync could delete everything.' })).toContain('Refresh')
40
+ })
41
+
42
+ it('warns that recreating a missing resource can undo a rollback', () => {
43
+ for (const sync of ['OutOfSync', 'Unknown', undefined]) {
44
+ expect(missingResourceMessage(sync, { rolledBack: true })).toBe(
45
+ 'Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.',
46
+ )
47
+ }
48
+ })
49
+
50
+ it('prioritizes the sync hazard over rollback advice', () => {
51
+ for (const sync of ['OutOfSync', 'Unknown', undefined]) {
52
+ expect(missingResourceMessage(sync, { hazard: 'Resolve shared tracking.', rolledBack: true })).toBe(
53
+ 'Not present in the cluster. See the sync warning above before syncing this Application.',
54
+ )
55
+ }
56
+ // Argo calling it Synced means the cluster view is stale: refresh, whatever the context.
57
+ expect(missingResourceMessage('Synced', { hazard: 'Resolve shared tracking.', rolledBack: true })).toContain('Refresh the application')
58
+ expect(missingResourceMessage('OutOfSync', {})).toContain('Sync the application')
59
+ })
60
+
33
61
  it('asks for a refresh when Argo still reports Synced', () => {
34
62
  expect(missingResourceMessage('Synced')).toContain('Refresh')
35
63
  expect(missingResourceMessage('Synced')).not.toContain('Sync the application')
@@ -47,9 +47,11 @@ export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
47
47
  return normalizeHealthStatus(change.health)
48
48
  }
49
49
 
50
- export function missingResourceMessage(sync?: string): string {
51
- return sync === 'Synced'
52
- ? 'Argo reports this resource as synced, but it is not present in the cluster. Refresh the application to check its status.'
50
+ export function missingResourceMessage(sync?: string, context?: { hazard?: string; rolledBack?: boolean }): string {
51
+ if (sync === 'Synced') return 'Argo reports this resource as synced, but it is not present in the cluster. Refresh the application to check its status.'
52
+ if (context?.hazard) return 'Not present in the cluster. See the sync warning above before syncing this Application.'
53
+ return context?.rolledBack
54
+ ? 'Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.'
53
55
  : 'Not present in the cluster. Sync the application to create it.'
54
56
  }
55
57
 
@@ -0,0 +1,190 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { GitOpsIssuesBand } from '../gitops/insights/GitOpsInsightViews'
6
+ import { IssueRow } from './IssuesView'
7
+ import { ResourceIssuesSection } from './ResourceIssuesSection'
8
+ import type { GitOpsIssue } from '../../types/gitops-insights'
9
+ import type { Issue } from './types'
10
+
11
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
12
+
13
+ const primary = '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.'
14
+ const secondary = [
15
+ 'If this Application should go away: remove it from the ApplicationSet generator if generated; while the generator still emits it, the ApplicationSet recreates it. Check whether the generated Application survives; its ApplicationSet sync policy may keep it. If it survives, delete it explicitly with the intended cascade choice. For a plain Application, delete the Application. Cleanup follows the cascade/resources finalizer choice and Delete=false / Delete=confirm, not Prune options.',
16
+ 'If it should render empty: set syncPolicy.automated.allowEmpty: true in the Application or its ApplicationSet template. This turns off the empty-render guard for all future syncs.',
17
+ ]
18
+ const title = 'If removing all managed resources is intended'
19
+ const cause = 'Argo CD blocked auto-sync because the render includes none of the resources this Application manages.'
20
+ const guard: GitOpsIssue = {
21
+ severity: 'critical', scope: 'condition', reason: 'AutoSyncBlockedEmpty',
22
+ message: 'Auto-sync blocked: the render includes none of the managed resources', cause,
23
+ action: [primary, ...secondary].join(' '), actionPrimary: primary,
24
+ actionSecondaryTitle: title, actionSecondary: secondary,
25
+ }
26
+ const issue: Issue = {
27
+ id: 'guard', severity: 'critical', source: 'problem',
28
+ category: 'gitops_sync_failed', category_group: 'gitops', grouping_scope: 'unknown',
29
+ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'empty',
30
+ reason: guard.reason, message: guard.message, cause,
31
+ action: guard.action, action_primary: primary,
32
+ action_secondary_title: title, action_secondary: secondary,
33
+ }
34
+
35
+ let root: Root
36
+ let element: HTMLDivElement
37
+ beforeEach(() => {
38
+ element = document.createElement('div')
39
+ document.body.appendChild(element)
40
+ root = createRoot(element)
41
+ })
42
+ afterEach(async () => {
43
+ await act(async () => root.unmount())
44
+ element.remove()
45
+ })
46
+
47
+ function disclosure() {
48
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(title))!
49
+ expect(button).toBeDefined()
50
+ return { button, panel: document.getElementById(button.getAttribute('aria-controls')!)! }
51
+ }
52
+
53
+ function checkPrimaryVisible() {
54
+ for (const text of [primary, cause]) {
55
+ const paragraph = [...element.querySelectorAll('p')].find((p) => p.textContent === text)!
56
+ expect(paragraph).toBeDefined()
57
+ expect(paragraph.closest('[inert]')).toBeNull()
58
+ }
59
+ expect(element.querySelector('button button')).toBeNull()
60
+ }
61
+
62
+ async function checkDisclosure() {
63
+ const { button, panel } = disclosure()
64
+ expect(button.getAttribute('aria-expanded')).toBe('false')
65
+ expect(panel.style.gridTemplateRows).toBe('0fr')
66
+ expect(panel.querySelector('[inert]')).not.toBeNull()
67
+ checkPrimaryVisible()
68
+ await act(async () => button.click())
69
+ expect(button.getAttribute('aria-expanded')).toBe('true')
70
+ expect(panel.style.gridTemplateRows).toBe('1fr')
71
+ expect(panel.querySelector('[inert]')).toBeNull()
72
+ for (const paragraph of secondary) expect(panel.textContent).toContain(paragraph)
73
+ await act(async () => button.click())
74
+ expect(button.getAttribute('aria-expanded')).toBe('false')
75
+ expect(panel.querySelector('[inert]')).not.toBeNull()
76
+ }
77
+
78
+ describe('empty-render guidance', () => {
79
+ it('shows primary guidance on the detail band and opens both intended-render paths', async () => {
80
+ await act(async () => root.render(<GitOpsIssuesBand issues={[guard]} />))
81
+ await checkDisclosure()
82
+ })
83
+
84
+ it('keeps the headline guidance independent of the collapsed detail stack', async () => {
85
+ const drift: GitOpsIssue = { severity: 'warning', scope: 'condition', reason: 'OtherWarning', message: 'Another condition' }
86
+ await act(async () => root.render(<GitOpsIssuesBand issues={[guard, drift]} />))
87
+ await checkDisclosure()
88
+ const stack = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes('AutoSyncBlockedEmpty'))!
89
+ expect(stack.getAttribute('aria-expanded')).toBe('false')
90
+ })
91
+
92
+ it('opens guidance on a lower detail row after expanding the stack', async () => {
93
+ const first: GitOpsIssue = { severity: 'critical', scope: 'condition', reason: 'ComparisonError', message: 'Cannot render' }
94
+ await act(async () => root.render(<GitOpsIssuesBand issues={[first, guard]} />))
95
+ const stack = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes('ComparisonError'))!
96
+ await act(async () => stack.click())
97
+ await checkDisclosure()
98
+ })
99
+
100
+ it.each([false, true])('shows split guidance on the cluster Issues row (open=%s)', async (open) => {
101
+ await act(async () => root.render(<IssueRow issue={issue} open={open} onToggle={() => {}} />))
102
+ await checkDisclosure()
103
+ })
104
+
105
+ it('shows primary guidance and disclosure in the resource drawer without expanding its issue', async () => {
106
+ await act(async () => root.render(<ResourceIssuesSection issues={[issue]} />))
107
+ await checkDisclosure()
108
+ })
109
+
110
+ it('shows complete action when presentation fields are absent', async () => {
111
+ const { actionPrimary: _primary, actionSecondaryTitle: _title, actionSecondary: _secondary, ...plain } = guard
112
+ await act(async () => root.render(<GitOpsIssuesBand issues={[plain]} />))
113
+ expect(element.textContent).toContain(guard.action)
114
+ expect(element.querySelectorAll('button[aria-controls]')).toHaveLength(0)
115
+ })
116
+ })
117
+
118
+ const moveSteps = [
119
+ "Check this Application's sync options for FailOnSharedResource=true. If set, the sync will refuse the takeover. Stop unless taking it over is intended and allowed.",
120
+ "Note the resource's live UID.",
121
+ 'Sync this Application.',
122
+ ]
123
+ const movePrimary = "To leave it with Application payments, remove it from this Application's source."
124
+ const moveTitle = 'To move it to this Application'
125
+ const move: GitOpsIssue = {
126
+ severity: 'warning', scope: 'condition', reason: 'SharedResourceWarning', message: 'Also tracked by payments',
127
+ action: `${movePrimary} ${moveTitle}: ${moveSteps.join(' ')}`, actionPrimary: movePrimary,
128
+ actionSecondaryTitle: moveTitle, actionSecondary: moveSteps,
129
+ }
130
+
131
+ describe('shared-resource move guidance', () => {
132
+ it.each(['detail', 'cluster'] as const)('renders numbered steps on the %s surface', async (surface) => {
133
+ const row: Issue = { ...issue, reason: move.reason, message: move.message, action: move.action,
134
+ action_primary: movePrimary, action_secondary_title: moveTitle, action_secondary: moveSteps }
135
+ await act(async () => root.render(surface === 'detail'
136
+ ? <GitOpsIssuesBand issues={[move]} />
137
+ : <IssueRow issue={row} open={true} onToggle={() => {}} />))
138
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(moveTitle))!
139
+ expect(button).toBeDefined()
140
+ await act(async () => button.click())
141
+ const panel = document.getElementById(button.getAttribute('aria-controls')!)!
142
+ expect([...panel.querySelectorAll('ol > li')].map((li) => li.textContent)).toEqual(moveSteps)
143
+ expect(panel.querySelector('[inert]')).toBeNull()
144
+ expect(element.textContent).toContain(movePrimary)
145
+ })
146
+ })
147
+
148
+ describe('cluster sync hazards', () => {
149
+ const hazard = 'Resolve shared ownership before syncing: another Application can take tracking back.'
150
+ it.each([undefined, { nextStep: 'Host next step', cause: 'Host cause' }])('keeps a warning ahead of any next-step source (%j)', async (diagnosisSource) => {
151
+ await act(async () => root.render(<IssueRow
152
+ issue={{ ...issue, action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined, hazard }}
153
+ open={true} onToggle={() => {}} diagnosisSource={diagnosisSource} />))
154
+ const warning = [...element.querySelectorAll('p')].find((p) => p.textContent === hazard)!
155
+ expect(warning).toBeDefined()
156
+ expect(warning.closest('.text-warning-text')).not.toBeNull()
157
+ expect(warning.closest('[inert]')).toBeNull()
158
+ expect(element.textContent!.indexOf(hazard)).toBeLessThan(element.textContent!.indexOf(diagnosisSource?.nextStep ?? primary))
159
+ if (diagnosisSource) expect(element.textContent).toContain(diagnosisSource.nextStep)
160
+ })
161
+
162
+ it('shows a hazard on a collapsed row with no split guidance or action', async () => {
163
+ await act(async () => root.render(<IssueRow
164
+ issue={{ ...issue, action: undefined, action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined, hazard }}
165
+ open={false} onToggle={() => {}} diagnosisSource={{ nextStep: 'Host next step' }} />))
166
+ const warning = [...element.querySelectorAll('p')].find((p) => p.textContent === hazard)!
167
+ expect(warning).toBeDefined()
168
+ expect(warning.closest('[inert]')).toBeNull()
169
+ expect(warning.closest('.text-warning-text')).not.toBeNull()
170
+ })
171
+ })
172
+
173
+ describe('hazard text retained only once in presentation', () => {
174
+ it.each([false, true])('keeps empty-render guidance and one warning (open=%s)', async (open) => {
175
+ await act(async () => root.render(<IssueRow issue={{ ...issue, hazard: primary }} open={open} onToggle={() => {}} />))
176
+ expect([...element.querySelectorAll('p')].filter((p) => p.textContent === primary)).toHaveLength(1)
177
+ await checkDisclosure()
178
+ })
179
+
180
+ it.each(['hazard only', 'appended hazard'])('keeps one warning for %s actions', async (mode) => {
181
+ const hazard = 'Sync can take over shared tracking.'
182
+ const nextStep = "Open the application's sync operation details."
183
+ const action = mode === 'hazard only' ? hazard : `${nextStep} ${hazard}`
184
+ await act(async () => root.render(<IssueRow issue={{ ...issue, hazard, action,
185
+ action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined }} open={true} onToggle={() => {}} />))
186
+ expect(element.textContent!.split(hazard)).toHaveLength(2)
187
+ if (mode === 'hazard only') expect(element.textContent).not.toContain('Next step')
188
+ else expect(element.textContent).toContain(nextStep)
189
+ })
190
+ })