@skyhook-io/k8s-ui 1.8.10 → 1.8.12
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/package.json +7 -6
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +697 -0
- package/src/components/applications/ApplicationDetail.tsx +3269 -474
- package/src/components/applications/ApplicationsView.tsx +62 -10
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +2 -3
- package/src/components/checks/severity.ts +14 -10
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +13 -6
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
- package/src/components/gitops/insights/index.ts +1 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
- package/src/components/gitops/insights/insights-helpers.ts +74 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
- package/src/components/issues/IssuesView.tsx +91 -52
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
- package/src/components/issues/ResourceIssuesSection.tsx +46 -144
- package/src/components/issues/index.ts +3 -0
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +102 -1
- package/src/components/issues/severity.ts +3 -0
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
- package/src/components/resources/ResourcesSidebar.tsx +46 -46
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +233 -51
- package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-argo.test.ts +18 -1
- package/src/components/resources/resource-utils-argo.ts +8 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +142 -111
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/shared/UnifiedDiff.tsx +51 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +375 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
- package/src/components/topology/TopologyControls.tsx +3 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +149 -90
- package/src/components/topology/TopologyOverlayBar.tsx +30 -0
- package/src/components/topology/TopologySearch.tsx +25 -17
- package/src/components/topology/index.ts +2 -0
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/SelectMenu.tsx +81 -0
- package/src/components/ui/drawer-components.tsx +48 -32
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +11 -4
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2706 -475
- package/src/components/workload/index.ts +1 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +2 -1
- package/src/types/core.ts +57 -4
- package/src/types/gitops-insights.ts +69 -1
- package/src/types/gitops.ts +3 -3
- package/src/utils/application-history.test.ts +538 -0
- package/src/utils/application-history.ts +257 -0
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +201 -1
- package/src/utils/applications.ts +1182 -393
- package/src/utils/index.ts +3 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +1024 -1
- package/src/utils/resource-hierarchy.ts +925 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AlertTriangle, ChevronDown, ChevronRight, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, Trash2 } from 'lucide-react'
|
|
1
|
+
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronUp, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
|
2
2
|
import { PaneLoader } from '../../ui/PaneLoader'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
@@ -7,11 +7,36 @@ import { HealthStatusBadge, SyncStatusBadge } from '../GitOpsStatusBadge'
|
|
|
7
7
|
import { SEVERITY_BADGE, SEVERITY_TEXT } from '../../../utils/badge-colors'
|
|
8
8
|
import { formatRelativeAgeTime } from '../../../utils/format'
|
|
9
9
|
import { Tooltip } from '../../ui/Tooltip'
|
|
10
|
-
import {
|
|
10
|
+
import { SearchBox } from '../../ui/SearchBox'
|
|
11
|
+
import { FilterPill, type FilterPillTone } from '../../ui/FilterPill'
|
|
12
|
+
import { Collapse, CollapseChevron } from '../../ui/Collapse'
|
|
13
|
+
import type { SortDir } from '../../ui/SortableTh'
|
|
14
|
+
import {
|
|
15
|
+
changeHealth,
|
|
16
|
+
changeMatchesFacets,
|
|
17
|
+
changeMatchesSearch,
|
|
18
|
+
changeSync,
|
|
19
|
+
compactSource,
|
|
20
|
+
entryTone,
|
|
21
|
+
gitopsToSeverity,
|
|
22
|
+
healthStatusRank,
|
|
23
|
+
isArgoResourceSyncEligible,
|
|
24
|
+
messageToPhase,
|
|
25
|
+
normalizeHealthStatus,
|
|
26
|
+
normalizeSyncStatus,
|
|
27
|
+
resourceStatusCounts,
|
|
28
|
+
syncStatusRank,
|
|
29
|
+
type ResourceSortKey,
|
|
30
|
+
type ResourceStatusFacet,
|
|
31
|
+
} from './insights-helpers'
|
|
11
32
|
|
|
12
33
|
interface GitOpsStatusStripProps {
|
|
13
34
|
insight?: GitOpsInsight | null
|
|
14
35
|
loading?: boolean
|
|
36
|
+
// Optional host slot: given the latest revision, render inline Git commit
|
|
37
|
+
// metadata (author, signature) next to the revision SHA. The host wires this
|
|
38
|
+
// to a data loader; when absent, only the SHA is shown.
|
|
39
|
+
renderRevisionMeta?: (revision: string) => ReactNode
|
|
15
40
|
}
|
|
16
41
|
|
|
17
42
|
// Status strip carries the operation chip (when a sync is in flight or
|
|
@@ -31,7 +56,7 @@ interface GitOpsStatusStripProps {
|
|
|
31
56
|
//
|
|
32
57
|
// Health and Sync badges live next to the title in the page header —
|
|
33
58
|
// pair them there with identity, not here.
|
|
34
|
-
export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps) {
|
|
59
|
+
export function GitOpsStatusStrip({ insight, loading, renderRevisionMeta }: GitOpsStatusStripProps) {
|
|
35
60
|
const summary = insight?.summary
|
|
36
61
|
if (loading) {
|
|
37
62
|
return <div className="h-8 animate-pulse border-b border-theme-border bg-theme-base" />
|
|
@@ -115,6 +140,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
|
|
|
115
140
|
</Tooltip>
|
|
116
141
|
)}
|
|
117
142
|
{reconcileAge && <span className="text-theme-text-tertiary">· {reconcileAge}</span>}
|
|
143
|
+
{revision && renderRevisionMeta?.(revision)}
|
|
118
144
|
</span>
|
|
119
145
|
)}
|
|
120
146
|
{!shortRev && reconcileAge && <MetaFact label="Last reconcile" value={reconcileAge} />}
|
|
@@ -203,11 +229,13 @@ function buildHealthSummary(changes: GitOpsChange[]): { text: string; tone: stri
|
|
|
203
229
|
let outOfSync = 0
|
|
204
230
|
let other = 0
|
|
205
231
|
for (const c of changes) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
else if (
|
|
232
|
+
// Count HEALTH, not sync — a Synced resource can still be Degraded, so check
|
|
233
|
+
// degraded/missing FIRST and never let a Synced sync-state mask it. A Synced
|
|
234
|
+
// resource with no assessed health (ConfigMaps etc.) still reads as fine.
|
|
235
|
+
if (c.health === 'Degraded' || c.category === 'Degraded') degraded++
|
|
236
|
+
else if (c.health === 'Missing' || c.category === 'Missing') missing++
|
|
237
|
+
else if (c.health === 'Healthy' || c.category === 'Synced') healthy++
|
|
238
|
+
else if (c.category === 'OutOfSync') outOfSync++
|
|
211
239
|
else other++
|
|
212
240
|
}
|
|
213
241
|
const total = changes.length
|
|
@@ -247,7 +275,7 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
|
|
|
247
275
|
{showHistorical ? '− Hide pre-deletion metadata' : '+ Show pre-deletion metadata'}
|
|
248
276
|
</button>
|
|
249
277
|
</div>
|
|
250
|
-
{showHistorical
|
|
278
|
+
<Collapse open={showHistorical}>
|
|
251
279
|
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 border-t border-theme-border/40 pt-2 text-[11px] text-theme-text-tertiary">
|
|
252
280
|
{summary.source && <MetaFact label="Source" value={summary.source} />}
|
|
253
281
|
{(summary.lastRevision || summary.targetRevision) && (
|
|
@@ -256,14 +284,14 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
|
|
|
256
284
|
{summary.lastReconcile && <MetaFact label="Last reconcile" value={formatRelative(summary.lastReconcile)} />}
|
|
257
285
|
{summary.autoSyncMode && <MetaFact label="Sync mode" value={summary.autoSyncMode} />}
|
|
258
286
|
</div>
|
|
259
|
-
|
|
287
|
+
</Collapse>
|
|
260
288
|
</div>
|
|
261
289
|
)
|
|
262
290
|
}
|
|
263
291
|
|
|
264
292
|
function isInFlightPhase(phase: string): boolean {
|
|
265
293
|
const p = phase.toLowerCase()
|
|
266
|
-
return p.includes('running') || p.includes('progress') || p.includes('reconcil')
|
|
294
|
+
return p.includes('running') || p.includes('terminat') || p.includes('progress') || p.includes('reconcil')
|
|
267
295
|
}
|
|
268
296
|
|
|
269
297
|
// Show the operation chip only for phases the operator needs to *act on*.
|
|
@@ -386,7 +414,7 @@ function GitOpsHistoricalIssuesDisclosure({
|
|
|
386
414
|
aria-expanded={expanded}
|
|
387
415
|
>
|
|
388
416
|
<div className="flex items-center gap-2">
|
|
389
|
-
|
|
417
|
+
<CollapseChevron open={expanded} className="h-3.5 w-3.5" />
|
|
390
418
|
<span className="text-[12px] font-medium text-theme-text-secondary">
|
|
391
419
|
Pre-deletion issues ({total})
|
|
392
420
|
</span>
|
|
@@ -395,12 +423,12 @@ function GitOpsHistoricalIssuesDisclosure({
|
|
|
395
423
|
</span>
|
|
396
424
|
</div>
|
|
397
425
|
</button>
|
|
398
|
-
{expanded
|
|
426
|
+
<Collapse open={expanded}>
|
|
399
427
|
<div className="border-t border-theme-border">
|
|
400
428
|
{operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
|
|
401
429
|
{others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
|
|
402
430
|
</div>
|
|
403
|
-
|
|
431
|
+
</Collapse>
|
|
404
432
|
</div>
|
|
405
433
|
)
|
|
406
434
|
}
|
|
@@ -488,15 +516,15 @@ function GitOpsFailureCard({
|
|
|
488
516
|
onClick={() => setShowRaw((v) => !v)}
|
|
489
517
|
className="inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
|
|
490
518
|
>
|
|
491
|
-
|
|
519
|
+
<CollapseChevron open={showRaw} className="h-3 w-3" />
|
|
492
520
|
{showRaw ? `Hide ${rawControllerLabel}` : `Show ${rawControllerLabel}`}
|
|
493
521
|
</button>
|
|
494
522
|
</div>
|
|
495
|
-
{showRaw
|
|
523
|
+
<Collapse open={showRaw}>
|
|
496
524
|
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border border-theme-border bg-theme-base px-3 py-2 font-mono text-[11px] text-theme-text-secondary">
|
|
497
525
|
{rawControllerMessage}
|
|
498
526
|
</pre>
|
|
499
|
-
|
|
527
|
+
</Collapse>
|
|
500
528
|
</div>
|
|
501
529
|
</div>
|
|
502
530
|
</div>
|
|
@@ -600,13 +628,10 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
600
628
|
Open {refText(headlineRef)} →
|
|
601
629
|
</span>
|
|
602
630
|
)}
|
|
603
|
-
{canExpand &&
|
|
604
|
-
expanded
|
|
605
|
-
? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
606
|
-
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
607
|
-
)}
|
|
631
|
+
{canExpand && <CollapseChevron open={expanded} className="h-3.5 w-3.5" />}
|
|
608
632
|
</button>
|
|
609
|
-
{
|
|
633
|
+
{canExpand && (
|
|
634
|
+
<Collapse open={expanded}>
|
|
610
635
|
<div className="divide-y divide-theme-border border-t border-theme-border bg-theme-base/40">
|
|
611
636
|
{issues.slice(1).map((issue: GitOpsIssue, index: number) => {
|
|
612
637
|
const t = severityTone(issue.severity)
|
|
@@ -644,6 +669,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
644
669
|
)
|
|
645
670
|
})}
|
|
646
671
|
</div>
|
|
672
|
+
</Collapse>
|
|
647
673
|
)}
|
|
648
674
|
</div>
|
|
649
675
|
)
|
|
@@ -685,6 +711,8 @@ interface GitOpsChangesViewProps {
|
|
|
685
711
|
insight?: GitOpsInsight | null
|
|
686
712
|
error?: Error | null
|
|
687
713
|
onOpenResource?: (ref: GitOpsChange['ref']) => void
|
|
714
|
+
onSyncResource?: (ref: GitOpsChange['ref']) => void
|
|
715
|
+
syncResourceDisabledReason?: string
|
|
688
716
|
// When set, the matching change row scrolls into view and gets a transient
|
|
689
717
|
// highlight ring. Used when the user clicks "View →" on an issue alert in
|
|
690
718
|
// the band above. Key shape: `${kind}/${namespace||''}/${name}` (group is
|
|
@@ -696,16 +724,45 @@ interface GitOpsChangesViewProps {
|
|
|
696
724
|
// Argo's default list-view behavior. Default mode still shows declared
|
|
697
725
|
// resources only — the diagnostic data (drift, events) lives there.
|
|
698
726
|
tree?: GitOpsResourceTree | null
|
|
727
|
+
// Host-wired loader for the full Git-rendered desired-vs-live diff of a
|
|
728
|
+
// single managed resource (Argo CD only). Data fetching lives in web/; this
|
|
729
|
+
// returns the loaded <ArgoResourceDiff/>. When present AND
|
|
730
|
+
// insight.capabilities.argoDiffAvailable, resolvable rows expose a "Full
|
|
731
|
+
// diff" toggle. Library consumers that don't wire it get no such affordance.
|
|
732
|
+
renderResourceDiff?: (ref: GitOpsInsightRef) => ReactNode
|
|
733
|
+
// Opens the global Settings dialog. Backs the "Connect Argo CD" hint shown
|
|
734
|
+
// when the root is an Argo Application without the diff integration.
|
|
735
|
+
onOpenSettings?: () => void
|
|
699
736
|
}
|
|
700
737
|
|
|
701
|
-
|
|
738
|
+
// Status facets for the Resources list. OutOfSync is a sync-status concern
|
|
739
|
+
// (amber/warn); Degraded + Missing are health failures (red/danger).
|
|
740
|
+
const STATUS_FACETS: { key: ResourceStatusFacet; label: string; tone: FilterPillTone }[] = [
|
|
741
|
+
{ key: 'outOfSync', label: 'Out of sync', tone: 'warn' },
|
|
742
|
+
{ key: 'degraded', label: 'Degraded', tone: 'danger' },
|
|
743
|
+
{ key: 'missing', label: 'Missing', tone: 'danger' },
|
|
744
|
+
]
|
|
745
|
+
|
|
746
|
+
export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResource, syncResourceDisabledReason, focusKey, tree, renderResourceDiff, onOpenSettings }: GitOpsChangesViewProps) {
|
|
702
747
|
// "All resources" toggle: when on, render generated descendants alongside
|
|
703
748
|
// the controller's declared inventory. Argo's UI defaults to "all" — we
|
|
704
749
|
// default to "declared" because the diagnostic data (drift, events) lives
|
|
705
750
|
// on declared resources only and the triage flow stays cleaner without
|
|
706
751
|
// 30+ Pod rows in the way. Operators who want the full picture flip it.
|
|
707
752
|
const [showAll, setShowAll] = useState(false)
|
|
753
|
+
// Resources list controls: free-text search (name/kind/namespace), status
|
|
754
|
+
// facets (the three states worth isolating on a drifted app — they replaced
|
|
755
|
+
// the per-resource OutOfSync "issues" that used to restate the whole table),
|
|
756
|
+
// and column sort. Default sort is 'order' — the controller's apply sequence
|
|
757
|
+
// — because it's the only sort under which the wave grouping headers read
|
|
758
|
+
// correctly.
|
|
759
|
+
const [search, setSearch] = useState('')
|
|
760
|
+
const [statusFilters, setStatusFilters] = useState<Set<ResourceStatusFacet>>(new Set())
|
|
761
|
+
const [sort, setSort] = useState<{ key: ResourceSortKey; dir: SortDir }>({ key: 'order', dir: 'asc' })
|
|
708
762
|
const changes = insight?.changes ?? []
|
|
763
|
+
// Keep object identity here: tree extras are newly allocated, while ordered
|
|
764
|
+
// controller changes retain their original references.
|
|
765
|
+
const declaredChanges = new Set(changes)
|
|
709
766
|
const plan = insight?.plan ?? []
|
|
710
767
|
// Synthesize Change rows for generated tree nodes that aren't already in
|
|
711
768
|
// the declared inventory. These rows carry less diagnostic data — no
|
|
@@ -718,13 +775,32 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
718
775
|
// map persists across renders so the effect can find the node even when
|
|
719
776
|
// changes re-render (e.g. polling).
|
|
720
777
|
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
|
778
|
+
// A deep-link (an issue's "View →") is an explicit "take me to this resource"
|
|
779
|
+
// intent. Clear any active search/facet filters so the target can't be
|
|
780
|
+
// filtered out of the list before we scroll to it. Functional updates keep
|
|
781
|
+
// this a no-op when no filter is active.
|
|
721
782
|
useEffect(() => {
|
|
722
783
|
if (!focusKey) return
|
|
784
|
+
setSearch((s) => (s ? '' : s))
|
|
785
|
+
setStatusFilters((f) => (f.size ? new Set<ResourceStatusFacet>() : f))
|
|
786
|
+
}, [focusKey])
|
|
787
|
+
// Scroll to the focused row once it has mounted. Re-runs when the filter
|
|
788
|
+
// state changes so that after the reset above remounts a previously
|
|
789
|
+
// filtered-out row, the scroll fires; the ref guard keeps it to one scroll
|
|
790
|
+
// per focusKey instead of re-scrolling on every poll re-render.
|
|
791
|
+
const scrolledFor = useRef<string | null>(null)
|
|
792
|
+
useEffect(() => {
|
|
793
|
+
if (!focusKey) {
|
|
794
|
+
scrolledFor.current = null
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
if (scrolledFor.current === focusKey) return
|
|
723
798
|
const node = rowRefs.current.get(focusKey)
|
|
724
799
|
if (node) {
|
|
725
800
|
node.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
801
|
+
scrolledFor.current = focusKey
|
|
726
802
|
}
|
|
727
|
-
}, [focusKey])
|
|
803
|
+
}, [focusKey, search, statusFilters])
|
|
728
804
|
// Distinguish "still loading" from "fetch failed" so a backend 5xx
|
|
729
805
|
// doesn't render as a stuck "Loading…".
|
|
730
806
|
if (error && !insight) {
|
|
@@ -734,22 +810,20 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
734
810
|
return <PaneLoader label="Loading GitOps resources…" className="h-full" />
|
|
735
811
|
}
|
|
736
812
|
// Build plan metadata maps keyed by ref so each Change row can advertise
|
|
737
|
-
// its
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
// change row.
|
|
813
|
+
// its hook phase and wave assignment. The plan and changes lists are the
|
|
814
|
+
// same resources from different angles — we fold the plan metadata onto
|
|
815
|
+
// each change row and order the list by the plan's apply sequence.
|
|
741
816
|
const planByRef = new Map<string, GitOpsPlanItem>()
|
|
742
817
|
for (const item of plan) {
|
|
743
818
|
const key = refKey(item.ref)
|
|
744
819
|
if (!planByRef.has(key)) planByRef.set(key, item)
|
|
745
820
|
}
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
const sortedChanges = [...changes, ...extraFromTree].sort((a, b) => {
|
|
821
|
+
// Apply-order baseline: the list in the sequence the controller reconciles.
|
|
822
|
+
// Changes without a plan entry land at the end in name order — managed
|
|
823
|
+
// resources the controller saw but didn't sequence (completed hooks,
|
|
824
|
+
// status-only entries). Extras-from-tree land after all declared rows.
|
|
825
|
+
// The column-sort/search/facet controls derive off this ordered baseline.
|
|
826
|
+
const applyOrdered = [...changes, ...extraFromTree].sort((a, b) => {
|
|
753
827
|
const ap = planByRef.get(refKey(a.ref))?.order
|
|
754
828
|
const bp = planByRef.get(refKey(b.ref))?.order
|
|
755
829
|
if (ap == null && bp == null) return refKey(a.ref).localeCompare(refKey(b.ref))
|
|
@@ -757,15 +831,48 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
757
831
|
if (bp == null) return -1
|
|
758
832
|
return ap - bp
|
|
759
833
|
})
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
834
|
+
const totalCount = applyOrdered.length
|
|
835
|
+
const counts = resourceStatusCounts(applyOrdered)
|
|
836
|
+
const filtered = applyOrdered.filter((c) => changeMatchesSearch(c, search) && changeMatchesFacets(c, statusFilters))
|
|
837
|
+
const displayChanges = sortChanges(filtered, sort)
|
|
838
|
+
const filtersActive = search.trim() !== '' || statusFilters.size > 0
|
|
839
|
+
// Wave grouping only reads correctly in apply order (ascending). Any column
|
|
840
|
+
// sort or a descending flip breaks the sequence, so drop the headers and
|
|
841
|
+
// render a flat list. Also skip them for single-wave / no-wave apps — an
|
|
842
|
+
// "always wave 0" label is noise.
|
|
843
|
+
const groupByWave = sort.key === 'order' && sort.dir === 'asc' && plan.some((i) => i.waveSet)
|
|
765
844
|
// Source URL for Missing rows. We can't show their live state (resource
|
|
766
845
|
// doesn't exist), but we CAN point at where they're declared in Git —
|
|
767
846
|
// which is the most useful thing to do when there's no drawer to open.
|
|
768
847
|
const sourceTreeURL = gitTreeURL(insight.summary.source, insight.summary.lastRevision || insight.summary.targetRevision || '')
|
|
848
|
+
// Comparison-coverage disclosure (Argo only). spec.ignoreDifferences hides
|
|
849
|
+
// fields from drift comparison; surfacing the count keeps the drift view
|
|
850
|
+
// honest about what it's NOT checking. Neutral note, not an alarm.
|
|
851
|
+
const ignoredDiffs = insight.summary.ignoredDifferences
|
|
852
|
+
const isArgoRoot = insight.summary.tool === 'argocd' && insight.summary.kind === 'Application'
|
|
853
|
+
// Full-diff affordance is offered only when the backend reports the Argo CD
|
|
854
|
+
// integration is connected for this app AND the host wired the loader.
|
|
855
|
+
const argoDiffAvailable = isArgoRoot && !!insight.capabilities?.argoDiffAvailable && !!renderResourceDiff
|
|
856
|
+
// When Argo can't build the desired state (ComparisonError — unreachable repo,
|
|
857
|
+
// missing revision, broken spec), it can't compare live-vs-Git for ANY
|
|
858
|
+
// resource, so every row's sync arrives Unknown. Those per-row Unknowns are
|
|
859
|
+
// all shadows of the one app-level failure already surfaced in the issues
|
|
860
|
+
// band above; rendering them as N alarming pills makes one problem look like
|
|
861
|
+
// many. When the app-level sync is Unknown, quiet the derivative per-row sync
|
|
862
|
+
// to a muted placeholder and explain the cause once. Health stays — it's a
|
|
863
|
+
// live signal that's still knowable and still worth reading.
|
|
864
|
+
const syncUnavailable = isArgoRoot && (insight.summary.sync ?? '').toLowerCase() === 'unknown'
|
|
865
|
+
const toggleFacet = (facet: ResourceStatusFacet) => {
|
|
866
|
+
setStatusFilters((prev) => {
|
|
867
|
+
const next = new Set(prev)
|
|
868
|
+
if (next.has(facet)) next.delete(facet)
|
|
869
|
+
else next.add(facet)
|
|
870
|
+
return next
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
const onSort = (key: ResourceSortKey) => {
|
|
874
|
+
setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' }))
|
|
875
|
+
}
|
|
769
876
|
return (
|
|
770
877
|
<div className="h-full overflow-auto bg-theme-base p-4">
|
|
771
878
|
<section className="rounded-md border border-theme-border bg-theme-surface">
|
|
@@ -781,7 +888,9 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
781
888
|
</Tooltip>
|
|
782
889
|
)}
|
|
783
890
|
<span className="text-[11px] tabular-nums text-theme-text-tertiary">
|
|
784
|
-
{
|
|
891
|
+
{filtersActive && displayChanges.length !== totalCount
|
|
892
|
+
? `${displayChanges.length} of ${totalCount}`
|
|
893
|
+
: `${totalCount} ${totalCount === 1 ? 'resource' : 'resources'}`}
|
|
785
894
|
</span>
|
|
786
895
|
</div>
|
|
787
896
|
{tree && (
|
|
@@ -817,11 +926,66 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
817
926
|
</div>
|
|
818
927
|
)}
|
|
819
928
|
</div>
|
|
929
|
+
{syncUnavailable && totalCount > 0 && (
|
|
930
|
+
<div className="flex items-start gap-2 border-b border-theme-border bg-theme-base/40 px-4 py-2 text-xs text-theme-text-secondary">
|
|
931
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
932
|
+
<span>
|
|
933
|
+
Argo CD hasn't compared this app against Git yet, so per-resource
|
|
934
|
+
<span className="font-medium text-theme-text-primary"> sync</span> status is unavailable
|
|
935
|
+
(a first refresh may still be in flight; if an error is shown above, resolve it to restore comparison).
|
|
936
|
+
<span className="font-medium text-theme-text-primary"> Health</span> below is still live.
|
|
937
|
+
</span>
|
|
938
|
+
</div>
|
|
939
|
+
)}
|
|
940
|
+
{/* Filter/sort toolbar. The status facets are the primary way to
|
|
941
|
+
answer "which resources are the problem?" now that the issues band
|
|
942
|
+
no longer restates every OutOfSync resource above. */}
|
|
943
|
+
{totalCount > 0 && (
|
|
944
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-theme-border px-4 py-2">
|
|
945
|
+
<SearchBox
|
|
946
|
+
value={search}
|
|
947
|
+
onChange={setSearch}
|
|
948
|
+
scope="gitops"
|
|
949
|
+
shortcutId="gitops-resources-search"
|
|
950
|
+
placeholder="Filter resources… (press /)"
|
|
951
|
+
className="w-full min-w-[180px] sm:w-64"
|
|
952
|
+
/>
|
|
953
|
+
{/* Status facets appear only when the app actually has that state
|
|
954
|
+
(or the operator has it toggled on) — a healthy app shouldn't
|
|
955
|
+
render three "(0)" pills. */}
|
|
956
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
957
|
+
{STATUS_FACETS.map(({ key, label, tone }) =>
|
|
958
|
+
counts[key] > 0 || statusFilters.has(key) ? (
|
|
959
|
+
<FilterPill
|
|
960
|
+
key={key}
|
|
961
|
+
label={label}
|
|
962
|
+
tone={tone}
|
|
963
|
+
active={statusFilters.has(key)}
|
|
964
|
+
count={counts[key]}
|
|
965
|
+
onClick={() => toggleFacet(key)}
|
|
966
|
+
/>
|
|
967
|
+
) : null,
|
|
968
|
+
)}
|
|
969
|
+
</div>
|
|
970
|
+
{sort.key !== 'order' && (
|
|
971
|
+
<button
|
|
972
|
+
type="button"
|
|
973
|
+
onClick={() => setSort({ key: 'order', dir: 'asc' })}
|
|
974
|
+
className="ml-auto inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
|
|
975
|
+
>
|
|
976
|
+
<ArrowUpDown className="h-3 w-3" /> Apply order
|
|
977
|
+
</button>
|
|
978
|
+
)}
|
|
979
|
+
</div>
|
|
980
|
+
)}
|
|
820
981
|
{/* Honest disclaimer about diff scope. Neither Argo nor Flux exposes
|
|
821
982
|
per-resource desired-vs-live diffs on the CRD — they're computed
|
|
822
983
|
on demand by their respective servers/CLIs, which Radar doesn't
|
|
823
|
-
call.
|
|
824
|
-
|
|
984
|
+
call. Suppressed for an Argo app that shows the connect/reconnect bar
|
|
985
|
+
below (which points at Settings), so the two don't stack with subtly
|
|
986
|
+
different advice — the CLI fallback stays for Flux and for consumers
|
|
987
|
+
that don't wire Settings. */}
|
|
988
|
+
{totalCount > 0 && !argoDiffAvailable && !(isArgoRoot && !!onOpenSettings) && (
|
|
825
989
|
<div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
826
990
|
Radar reads each resource's drift status from the controller. For a line-by-line diff, {insight.summary.tool === 'fluxcd' ? (
|
|
827
991
|
insight.summary.kind === 'HelmRelease' ? (
|
|
@@ -834,66 +998,200 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
834
998
|
)}
|
|
835
999
|
</div>
|
|
836
1000
|
)}
|
|
837
|
-
{
|
|
838
|
-
<div className="
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1001
|
+
{totalCount > 0 && isArgoRoot && !insight.capabilities?.argoDiffAvailable && onOpenSettings && (
|
|
1002
|
+
<div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
1003
|
+
{insight.capabilities?.argoConfigured ? (
|
|
1004
|
+
<>
|
|
1005
|
+
Argo CD's full Git-rendered diff isn't available for this app (connection down or token not authorized).{' '}
|
|
1006
|
+
<button type="button" onClick={onOpenSettings} className="font-medium text-accent-text hover:underline">
|
|
1007
|
+
Check Argo CD in Settings
|
|
1008
|
+
</button>.
|
|
1009
|
+
</>
|
|
1010
|
+
) : (
|
|
1011
|
+
<>
|
|
1012
|
+
<button type="button" onClick={onOpenSettings} className="font-medium text-accent-text hover:underline">
|
|
1013
|
+
Connect Argo CD
|
|
1014
|
+
</button>{' '}
|
|
1015
|
+
for the full Git-rendered diff.
|
|
1016
|
+
</>
|
|
1017
|
+
)}
|
|
1018
|
+
</div>
|
|
1019
|
+
)}
|
|
1020
|
+
{ignoredDiffs && ignoredDiffs.ruleCount > 0 && (
|
|
1021
|
+
<div className="flex items-center gap-1.5 border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
1022
|
+
<span>
|
|
1023
|
+
Some fields are intentionally excluded from drift comparison, so a resource can read as in sync
|
|
1024
|
+
even if those fields differ.
|
|
1025
|
+
</span>
|
|
1026
|
+
<Tooltip
|
|
1027
|
+
content={
|
|
1028
|
+
<div className="max-w-[46ch] space-y-1 text-left">
|
|
1029
|
+
<div>
|
|
1030
|
+
{ignoredDiffs.ruleCount} exclusion {ignoredDiffs.ruleCount === 1 ? 'rule' : 'rules'} configured
|
|
1031
|
+
{ignoredDiffs.kinds.length > 0 && <> on {ignoredDiffs.kinds.join(', ')}</>} (Argo CD{' '}
|
|
1032
|
+
<code className="inline-code">spec.ignoreDifferences</code>).
|
|
1033
|
+
</div>
|
|
1034
|
+
{ignoredDiffs.unsupportedRuleCount > 0 && (
|
|
1035
|
+
<div>
|
|
1036
|
+
{ignoredDiffs.unsupportedRuleCount} of them use jq expressions or managed-fields managers,
|
|
1037
|
+
which Radar doesn't evaluate — so a few fields Argo hides may still appear here.
|
|
867
1038
|
</div>
|
|
868
1039
|
)}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1040
|
+
</div>
|
|
1041
|
+
}
|
|
1042
|
+
delay={200}
|
|
1043
|
+
>
|
|
1044
|
+
<span className="inline-flex cursor-help items-center text-theme-text-tertiary hover:text-theme-text-secondary">
|
|
1045
|
+
<Info className="h-3 w-3" />
|
|
1046
|
+
<span className="ml-1 underline decoration-dotted underline-offset-2">details</span>
|
|
1047
|
+
</span>
|
|
1048
|
+
</Tooltip>
|
|
1049
|
+
</div>
|
|
1050
|
+
)}
|
|
1051
|
+
{totalCount === 0 ? (
|
|
1052
|
+
<div className="p-4 text-sm text-theme-text-secondary">No managed resources reported by the GitOps controller.</div>
|
|
1053
|
+
) : displayChanges.length === 0 ? (
|
|
1054
|
+
<div className="flex flex-col items-start gap-2 p-4 text-sm text-theme-text-secondary">
|
|
1055
|
+
<span>No resources match the current filters.</span>
|
|
1056
|
+
<button
|
|
1057
|
+
type="button"
|
|
1058
|
+
onClick={() => {
|
|
1059
|
+
setSearch('')
|
|
1060
|
+
setStatusFilters(new Set())
|
|
1061
|
+
}}
|
|
1062
|
+
className="text-[12px] font-medium text-skyhook-500 hover:underline"
|
|
1063
|
+
>
|
|
1064
|
+
Clear filters
|
|
1065
|
+
</button>
|
|
890
1066
|
</div>
|
|
1067
|
+
) : (
|
|
1068
|
+
<>
|
|
1069
|
+
<div className="grid grid-cols-[minmax(0,1fr)_120px_120px_220px] gap-3 border-b border-theme-border bg-theme-base/40 px-4 py-2">
|
|
1070
|
+
<ResSortHeader label="Resource" sortKey="name" sort={sort} onSort={onSort} />
|
|
1071
|
+
<ResSortHeader label="Sync" sortKey="sync" sort={sort} onSort={onSort} />
|
|
1072
|
+
<ResSortHeader label="Health" sortKey="health" sort={sort} onSort={onSort} />
|
|
1073
|
+
<div />
|
|
1074
|
+
</div>
|
|
1075
|
+
<div className="divide-y divide-theme-border">
|
|
1076
|
+
{displayChanges.map((change, idx) => {
|
|
1077
|
+
const planItem = planByRef.get(refKey(change.ref))
|
|
1078
|
+
const hook = planItem?.hook
|
|
1079
|
+
const wave = planItem?.wave
|
|
1080
|
+
const waveSet = !!planItem?.waveSet
|
|
1081
|
+
const rowKey = refKey(change.ref)
|
|
1082
|
+
const focused = focusKey === rowKey
|
|
1083
|
+
const explanation = !change.syncError && !change.message
|
|
1084
|
+
? explainChangeStatus(change.sync, change.health, insight.summary)
|
|
1085
|
+
: ''
|
|
1086
|
+
const hasInlineDetail = !!(
|
|
1087
|
+
(change.drift && change.drift.entries.length > 0) ||
|
|
1088
|
+
(change.recentEvents && change.recentEvents.length > 0)
|
|
1089
|
+
)
|
|
1090
|
+
// Change rows come from the Application's status.resources, so
|
|
1091
|
+
// they are in the Argo managed set by construction — the diff
|
|
1092
|
+
// endpoint can serve every one (a Missing resource just has an
|
|
1093
|
+
// empty live side). NOT gated on change.hasDesired/partial:
|
|
1094
|
+
// those describe Radar's LOCAL last-applied view, and the Argo
|
|
1095
|
+
// API diff exists precisely for the rows where it's absent.
|
|
1096
|
+
const canFullDiff = argoDiffAvailable && !!change.ref.kind && !!change.ref.name
|
|
1097
|
+
// Render a wave separator above this row when the wave value
|
|
1098
|
+
// changed from the previous one. waveSet=false rows under a
|
|
1099
|
+
// waved plan get a "Default wave" header — matches how Argo's
|
|
1100
|
+
// UI separates explicitly-waved from default. Only in apply
|
|
1101
|
+
// order (groupByWave); a column sort renders a flat list.
|
|
1102
|
+
const prevPlan = idx > 0 ? planByRef.get(refKey(displayChanges[idx - 1]!.ref)) : undefined
|
|
1103
|
+
const showWaveHeader = groupByWave && (idx === 0 || prevPlan?.wave !== wave || prevPlan?.waveSet !== waveSet)
|
|
1104
|
+
return (
|
|
1105
|
+
<Fragment key={`${change.ref.group}/${change.ref.kind}/${change.ref.namespace}/${change.ref.name}`}>
|
|
1106
|
+
{showWaveHeader && (
|
|
1107
|
+
<div className="bg-theme-base/50 px-4 py-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1108
|
+
{waveSet ? `Wave ${wave}` : 'Default wave'}
|
|
1109
|
+
</div>
|
|
1110
|
+
)}
|
|
1111
|
+
<ChangeRow
|
|
1112
|
+
change={change}
|
|
1113
|
+
hook={hook}
|
|
1114
|
+
explanation={explanation}
|
|
1115
|
+
syncUnavailable={syncUnavailable}
|
|
1116
|
+
focused={focused}
|
|
1117
|
+
autoExpand={focused}
|
|
1118
|
+
hasInlineDetail={hasInlineDetail}
|
|
1119
|
+
canFullDiff={canFullDiff}
|
|
1120
|
+
renderResourceDiff={renderResourceDiff}
|
|
1121
|
+
onOpenResource={onOpenResource}
|
|
1122
|
+
onSyncResource={onSyncResource}
|
|
1123
|
+
syncResourceDisabledReason={syncResourceDisabledReason}
|
|
1124
|
+
declared={declaredChanges.has(change)}
|
|
1125
|
+
sourceTreeURL={sourceTreeURL}
|
|
1126
|
+
registerRef={(el) => {
|
|
1127
|
+
if (el) {
|
|
1128
|
+
rowRefs.current.set(rowKey, el)
|
|
1129
|
+
} else {
|
|
1130
|
+
rowRefs.current.delete(rowKey)
|
|
1131
|
+
}
|
|
1132
|
+
}}
|
|
1133
|
+
/>
|
|
1134
|
+
</Fragment>
|
|
1135
|
+
)
|
|
1136
|
+
})}
|
|
1137
|
+
</div>
|
|
1138
|
+
</>
|
|
891
1139
|
)}
|
|
892
1140
|
</section>
|
|
893
1141
|
</div>
|
|
894
1142
|
)
|
|
895
1143
|
}
|
|
896
1144
|
|
|
1145
|
+
// sortChanges applies the active column sort to the already apply-ordered
|
|
1146
|
+
// list. 'order' is the identity ordering (the list arrives in apply sequence),
|
|
1147
|
+
// so ascending returns it untouched and descending reverses. Column sorts
|
|
1148
|
+
// tie-break on kind/name so equal-status rows stay stable and readable.
|
|
1149
|
+
function sortChanges(list: GitOpsChange[], sort: { key: ResourceSortKey; dir: SortDir }): GitOpsChange[] {
|
|
1150
|
+
if (sort.key === 'order') return sort.dir === 'asc' ? list : [...list].reverse()
|
|
1151
|
+
const dir = sort.dir === 'asc' ? 1 : -1
|
|
1152
|
+
const nameKey = (c: GitOpsChange) => `${c.ref.kind}/${c.ref.name}`
|
|
1153
|
+
return [...list].sort((a, b) => {
|
|
1154
|
+
let cmp = 0
|
|
1155
|
+
if (sort.key === 'name') cmp = nameKey(a).localeCompare(nameKey(b))
|
|
1156
|
+
else if (sort.key === 'sync') cmp = syncStatusRank(changeSync(a)) - syncStatusRank(changeSync(b))
|
|
1157
|
+
else if (sort.key === 'health') cmp = healthStatusRank(changeHealth(a)) - healthStatusRank(changeHealth(b))
|
|
1158
|
+
if (cmp === 0) cmp = nameKey(a).localeCompare(nameKey(b))
|
|
1159
|
+
return cmp * dir
|
|
1160
|
+
})
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ResSortHeader is the div-grid equivalent of ui/SortableTh (which renders a
|
|
1164
|
+
// <th>): the Resources list is a grid of expandable rows, not a <table>, so it
|
|
1165
|
+
// can't use the table header cell. Same chevron affordance for consistency.
|
|
1166
|
+
function ResSortHeader({
|
|
1167
|
+
label,
|
|
1168
|
+
sortKey,
|
|
1169
|
+
sort,
|
|
1170
|
+
onSort,
|
|
1171
|
+
}: {
|
|
1172
|
+
label: string
|
|
1173
|
+
sortKey: ResourceSortKey
|
|
1174
|
+
sort: { key: ResourceSortKey; dir: SortDir }
|
|
1175
|
+
onSort: (key: ResourceSortKey) => void
|
|
1176
|
+
}) {
|
|
1177
|
+
const active = sort.key === sortKey
|
|
1178
|
+
return (
|
|
1179
|
+
<button
|
|
1180
|
+
type="button"
|
|
1181
|
+
onClick={() => onSort(sortKey)}
|
|
1182
|
+
aria-label={`Sort by ${label}`}
|
|
1183
|
+
className="inline-flex items-center gap-1 select-none text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary transition-colors hover:text-theme-text-secondary focus-visible:text-theme-text-secondary focus-visible:outline-none"
|
|
1184
|
+
>
|
|
1185
|
+
{label}
|
|
1186
|
+
{active ? (
|
|
1187
|
+
sort.dir === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
|
|
1188
|
+
) : (
|
|
1189
|
+
<ArrowUpDown className="h-3 w-3 opacity-40" />
|
|
1190
|
+
)}
|
|
1191
|
+
</button>
|
|
1192
|
+
)
|
|
1193
|
+
}
|
|
1194
|
+
|
|
897
1195
|
// Keys must match between Plan items and Change items for the cross-ref to
|
|
898
1196
|
// work. Group can be omitted in either source, so we don't require it for
|
|
899
1197
|
// equality — kind+namespace+name is the practical identifier here.
|
|
@@ -990,27 +1288,41 @@ function explainChangeStatus(
|
|
|
990
1288
|
// "what's the underlying cluster reason?"
|
|
991
1289
|
function ChangeRow({
|
|
992
1290
|
change,
|
|
993
|
-
step,
|
|
994
1291
|
hook,
|
|
995
1292
|
explanation,
|
|
1293
|
+
syncUnavailable,
|
|
996
1294
|
focused,
|
|
997
1295
|
autoExpand,
|
|
998
1296
|
hasInlineDetail,
|
|
1297
|
+
canFullDiff,
|
|
1298
|
+
renderResourceDiff,
|
|
999
1299
|
onOpenResource,
|
|
1300
|
+
onSyncResource,
|
|
1301
|
+
syncResourceDisabledReason,
|
|
1302
|
+
declared,
|
|
1000
1303
|
sourceTreeURL,
|
|
1001
1304
|
registerRef,
|
|
1002
1305
|
}: {
|
|
1003
1306
|
change: GitOpsChange
|
|
1004
|
-
step: number | undefined
|
|
1005
1307
|
// Hook phase from the plan item (the controller-declared annotation).
|
|
1006
1308
|
// Falls back to change.hookPhase (the executed phase) for visibility on
|
|
1007
1309
|
// resources that already ran their hook.
|
|
1008
1310
|
hook: string | undefined
|
|
1009
1311
|
explanation: string
|
|
1312
|
+
// True when the parent app can't compare against Git at all (ComparisonError):
|
|
1313
|
+
// this row's Unknown sync is derivative, so render it muted instead of loud.
|
|
1314
|
+
syncUnavailable: boolean
|
|
1010
1315
|
focused: boolean
|
|
1011
1316
|
autoExpand: boolean
|
|
1012
1317
|
hasInlineDetail: boolean
|
|
1318
|
+
// When true, the expanded panel offers a "Full diff" toggle that mounts the
|
|
1319
|
+
// host-wired Argo CD diff loader for this resource.
|
|
1320
|
+
canFullDiff: boolean
|
|
1321
|
+
renderResourceDiff?: (ref: GitOpsInsightRef) => ReactNode
|
|
1013
1322
|
onOpenResource?: (ref: GitOpsChange['ref']) => void
|
|
1323
|
+
onSyncResource?: (ref: GitOpsChange['ref']) => void
|
|
1324
|
+
syncResourceDisabledReason?: string
|
|
1325
|
+
declared: boolean
|
|
1014
1326
|
// Constructed URL pointing at the source directory in the remote Git
|
|
1015
1327
|
// host (github / gitlab / bitbucket). Used as the "where this would be
|
|
1016
1328
|
// declared" affordance on Missing rows, since opening the drawer for a
|
|
@@ -1018,12 +1330,15 @@ function ChangeRow({
|
|
|
1018
1330
|
sourceTreeURL?: string | null
|
|
1019
1331
|
registerRef: (el: HTMLDivElement | null) => void
|
|
1020
1332
|
}) {
|
|
1021
|
-
|
|
1333
|
+
// A row expands when it has inline diagnostics (drift/events) or can load a
|
|
1334
|
+
// full Argo CD diff — both live in the same disclosure panel.
|
|
1335
|
+
const expandable = hasInlineDetail || canFullDiff
|
|
1336
|
+
const [expanded, setExpanded] = useState(autoExpand && expandable)
|
|
1022
1337
|
// Auto-expand when an issue alert deep-links to this row — the user just
|
|
1023
1338
|
// clicked the issue, so they want to see the detail immediately.
|
|
1024
1339
|
useEffect(() => {
|
|
1025
|
-
if (autoExpand &&
|
|
1026
|
-
}, [autoExpand,
|
|
1340
|
+
if (autoExpand && expandable) setExpanded(true)
|
|
1341
|
+
}, [autoExpand, expandable])
|
|
1027
1342
|
const driftEntries = change.drift?.entries ?? []
|
|
1028
1343
|
const events = change.recentEvents ?? []
|
|
1029
1344
|
// Missing resources have no live state to drill into — opening the drawer
|
|
@@ -1033,17 +1348,29 @@ function ChangeRow({
|
|
|
1033
1348
|
// declared source instead of a non-existent live object.
|
|
1034
1349
|
const isAbsent = change.health === 'Missing' && !change.hasLive
|
|
1035
1350
|
const handleRowClick = () => {
|
|
1036
|
-
if (
|
|
1351
|
+
if (expandable) {
|
|
1037
1352
|
setExpanded((v) => !v)
|
|
1038
1353
|
} else if (!isAbsent && onOpenResource) {
|
|
1039
1354
|
onOpenResource(change.ref)
|
|
1040
1355
|
}
|
|
1041
1356
|
}
|
|
1042
|
-
// Row stays click-affordant when there's
|
|
1357
|
+
// Row stays click-affordant when there's a disclosure panel to expand OR a
|
|
1043
1358
|
// live resource to drill into. Missing rows without inline detail are
|
|
1044
1359
|
// intentionally non-interactive — there's nowhere useful to go.
|
|
1045
|
-
const rowInteractive =
|
|
1360
|
+
const rowInteractive = expandable || (!isAbsent && !!onOpenResource)
|
|
1046
1361
|
const hookLabel = change.hookPhase || hook
|
|
1362
|
+
const canSyncResource = !!onSyncResource && isArgoResourceSyncEligible(change, declared, hookLabel)
|
|
1363
|
+
const syncResourceButton = canSyncResource ? (
|
|
1364
|
+
<button
|
|
1365
|
+
type="button"
|
|
1366
|
+
onClick={() => onSyncResource(change.ref)}
|
|
1367
|
+
disabled={!!syncResourceDisabledReason}
|
|
1368
|
+
className="inline-flex w-full items-center justify-center gap-1.5 rounded border border-theme-border bg-theme-base px-2 py-1 text-[11px] font-medium text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
1369
|
+
>
|
|
1370
|
+
<RefreshCw className="h-3 w-3" />
|
|
1371
|
+
Sync resource
|
|
1372
|
+
</button>
|
|
1373
|
+
) : null
|
|
1047
1374
|
return (
|
|
1048
1375
|
<div
|
|
1049
1376
|
ref={registerRef}
|
|
@@ -1067,20 +1394,11 @@ function ChangeRow({
|
|
|
1067
1394
|
)}
|
|
1068
1395
|
>
|
|
1069
1396
|
<div className="flex items-baseline gap-2">
|
|
1070
|
-
{
|
|
1071
|
-
expanded
|
|
1072
|
-
? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
1073
|
-
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
1397
|
+
{expandable ? (
|
|
1398
|
+
<CollapseChevron open={expanded} className="h-3.5 w-3.5 self-center" />
|
|
1074
1399
|
) : (
|
|
1075
1400
|
<span aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
1076
1401
|
)}
|
|
1077
|
-
{step !== undefined && (
|
|
1078
|
-
<Tooltip content={`Sync plan step ${step}`} delay={200} wrapperClassName="shrink-0">
|
|
1079
|
-
<span className="rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 font-mono text-[10px] text-theme-text-tertiary">
|
|
1080
|
-
step {step}
|
|
1081
|
-
</span>
|
|
1082
|
-
</Tooltip>
|
|
1083
|
-
)}
|
|
1084
1402
|
<div className="min-w-0 truncate font-medium text-theme-text-primary">{change.ref.kind} / {change.ref.name}</div>
|
|
1085
1403
|
{hookLabel && (
|
|
1086
1404
|
<Tooltip content={`Sync hook: ${hookLabel}`} delay={200} wrapperClassName="shrink-0">
|
|
@@ -1126,9 +1444,22 @@ function ChangeRow({
|
|
|
1126
1444
|
<div className="ml-[18px] mt-1 text-xs text-theme-text-tertiary">{explanation}</div>
|
|
1127
1445
|
)}
|
|
1128
1446
|
</button>
|
|
1129
|
-
<div className="self-start"><SyncStatusBadge sync={normalizeSyncStatus(change.sync ?? change.category)} /></div>
|
|
1130
|
-
<div className="self-start"><HealthStatusBadge health={normalizeHealthStatus(change.health)} /></div>
|
|
1131
1447
|
<div className="self-start">
|
|
1448
|
+
{syncUnavailable && normalizeSyncStatus(change.sync ?? change.category) === 'Unknown' ? (
|
|
1449
|
+
<Tooltip content="Sync unavailable — Argo CD hasn't compared this app against Git yet." delay={200} wrapperClassName="inline-flex">
|
|
1450
|
+
<span className="cursor-help select-none px-1 text-theme-text-tertiary" aria-label="Sync status unavailable">—</span>
|
|
1451
|
+
</Tooltip>
|
|
1452
|
+
) : (
|
|
1453
|
+
<SyncStatusBadge sync={normalizeSyncStatus(change.sync ?? change.category)} />
|
|
1454
|
+
)}
|
|
1455
|
+
</div>
|
|
1456
|
+
<div className="self-start"><HealthStatusBadge health={normalizeHealthStatus(change.health)} /></div>
|
|
1457
|
+
<div className="self-start space-y-1.5">
|
|
1458
|
+
{syncResourceButton && syncResourceDisabledReason ? (
|
|
1459
|
+
<Tooltip content={syncResourceDisabledReason} delay={200} wrapperClassName="block">
|
|
1460
|
+
{syncResourceButton}
|
|
1461
|
+
</Tooltip>
|
|
1462
|
+
) : syncResourceButton}
|
|
1132
1463
|
{/* Three affordance states:
|
|
1133
1464
|
- Live resource (not Missing): "Open <kind> <name> →" opens the
|
|
1134
1465
|
K8s drawer.
|
|
@@ -1167,11 +1498,22 @@ function ChangeRow({
|
|
|
1167
1498
|
)}
|
|
1168
1499
|
</div>
|
|
1169
1500
|
</div>
|
|
1170
|
-
{
|
|
1171
|
-
<
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1501
|
+
{expandable && (
|
|
1502
|
+
<Collapse open={expanded} mountLazily>
|
|
1503
|
+
<div className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
|
|
1504
|
+
{driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
|
|
1505
|
+
{events.length > 0 && <RecentEventsPanel events={events} />}
|
|
1506
|
+
{/* Expanding a row IS the request to see its diff — render it inline
|
|
1507
|
+
rather than behind a second "Full diff" click. The capability is
|
|
1508
|
+
already gated on a live Argo connection, so this only mounts (and
|
|
1509
|
+
fetches) when the diff can actually be served. */}
|
|
1510
|
+
{canFullDiff && (
|
|
1511
|
+
<div className={clsx((driftEntries.length > 0 || events.length > 0) && 'mt-3')}>
|
|
1512
|
+
{renderResourceDiff?.(change.ref)}
|
|
1513
|
+
</div>
|
|
1514
|
+
)}
|
|
1515
|
+
</div>
|
|
1516
|
+
</Collapse>
|
|
1175
1517
|
)}
|
|
1176
1518
|
</div>
|
|
1177
1519
|
)
|
|
@@ -1182,6 +1524,7 @@ function ChangeRow({
|
|
|
1182
1524
|
// inline as "old → new". Path is monospace; values are JSON-encoded and
|
|
1183
1525
|
// pre-wrapped so structured values (objects, arrays) render readably.
|
|
1184
1526
|
function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
|
|
1527
|
+
const sourceLabel = driftSourceLabel(drift.source)
|
|
1185
1528
|
return (
|
|
1186
1529
|
<div>
|
|
1187
1530
|
<div className="mb-2 flex items-baseline justify-between gap-2">
|
|
@@ -1189,6 +1532,7 @@ function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
|
|
|
1189
1532
|
<span className="text-[10px] text-theme-text-tertiary">
|
|
1190
1533
|
desired (Git) → live ·
|
|
1191
1534
|
{drift.truncated ? ' showing first 50 entries' : ` ${drift.entries.length} field${drift.entries.length === 1 ? '' : 's'}`}
|
|
1535
|
+
{sourceLabel && ` · ${sourceLabel}`}
|
|
1192
1536
|
</span>
|
|
1193
1537
|
</div>
|
|
1194
1538
|
<div className="space-y-1 font-mono text-[11px]">
|
|
@@ -1200,6 +1544,15 @@ function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
|
|
|
1200
1544
|
)
|
|
1201
1545
|
}
|
|
1202
1546
|
|
|
1547
|
+
// Provenance label for a Drift's field entries. Only Argo-sourced drift gets an
|
|
1548
|
+
// explicit label; the built-in last-applied path renders without one (the
|
|
1549
|
+
// "desired (Git) → live" copy already implies it). Unknown sources (a library
|
|
1550
|
+
// consumer lagging the backend) fall through to no label rather than surfacing
|
|
1551
|
+
// a raw enum string.
|
|
1552
|
+
function driftSourceLabel(source: NonNullable<GitOpsChange['drift']>['source']): string {
|
|
1553
|
+
return source === 'argocd-api' ? 'via Argo CD API' : ''
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1203
1556
|
function DriftEntryRow({ entry }: { entry: NonNullable<GitOpsChange['drift']>['entries'][number] }) {
|
|
1204
1557
|
if (entry.op === 'removed') {
|
|
1205
1558
|
return (
|