@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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsTableView.tsx +13 -8
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
- package/src/components/gitops/insights/insights-helpers.ts +5 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +17 -5
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +2 -0
- package/src/components/issues/types.ts +10 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.tsx +26 -5
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.tsx +238 -66
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +14 -3
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.tsx +135 -2
- package/src/components/shared/ResourceActionsBar.tsx +22 -6
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/PageHeader.tsx +3 -2
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +27 -0
- package/src/components/ui/drawer-components.tsx +22 -42
- package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
- package/src/components/workload/WorkloadView.tsx +57 -34
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/types/core.ts +54 -1
- package/src/types/gitops-insights.ts +8 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/gitops-owner.ts +136 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +1 -0
- package/src/utils/read-actions.ts +34 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- 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
|
-
<
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
</
|
|
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
|
|
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),
|
|
444
|
-
// resource, retry posture, raw controller
|
|
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
|
-
<
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
+
})
|