@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.
Files changed (168) hide show
  1. package/package.json +7 -6
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +697 -0
  6. package/src/components/applications/ApplicationDetail.tsx +3269 -474
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +13 -6
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  21. package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
  22. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  23. package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
  24. package/src/components/gitops/insights/index.ts +1 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
  26. package/src/components/gitops/insights/insights-helpers.ts +74 -1
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
  28. package/src/components/issues/IssuesView.tsx +91 -52
  29. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
  30. package/src/components/issues/ResourceIssuesSection.tsx +46 -144
  31. package/src/components/issues/index.ts +3 -0
  32. package/src/components/issues/issue-timing.ts +53 -0
  33. package/src/components/issues/issues.test.ts +102 -1
  34. package/src/components/issues/severity.ts +3 -0
  35. package/src/components/logs/LogCore.tsx +14 -4
  36. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  37. package/src/components/logs/useLogStream.ts +8 -1
  38. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  39. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  40. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  41. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  42. package/src/components/resources/ResourcesView.tsx +233 -51
  43. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  54. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  55. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  56. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  57. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  58. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  59. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  60. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  61. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  62. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  63. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  64. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  66. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  67. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  68. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  69. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  70. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  71. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  72. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  73. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  75. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  76. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  77. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  78. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  79. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  80. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  81. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  82. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  83. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  84. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  85. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  86. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  87. package/src/components/resources/renderers/index.ts +1 -0
  88. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  89. package/src/components/resources/resource-utils-argo.ts +8 -0
  90. package/src/components/resources/resource-utils-capi.ts +1 -1
  91. package/src/components/resources/resource-utils.ts +31 -0
  92. package/src/components/shared/DetailShell.tsx +2 -2
  93. package/src/components/shared/ResourceActionsBar.tsx +142 -111
  94. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  95. package/src/components/shared/UnifiedDiff.tsx +51 -0
  96. package/src/components/shared/index.ts +1 -0
  97. package/src/components/timeline/TimelineList.tsx +375 -354
  98. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  99. package/src/components/timeline/TimelineStrip.tsx +836 -0
  100. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  101. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  102. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  103. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  104. package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
  105. package/src/components/timeline/index.ts +23 -0
  106. package/src/components/timeline/scrubber-math.test.ts +235 -0
  107. package/src/components/timeline/scrubber-math.ts +249 -0
  108. package/src/components/timeline/shared.test.tsx +219 -0
  109. package/src/components/timeline/shared.tsx +232 -447
  110. package/src/components/timeline/timeline-filters.test.ts +196 -0
  111. package/src/components/timeline/timeline-filters.ts +180 -0
  112. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  113. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  114. package/src/components/timeline/timeline-live.test.ts +131 -0
  115. package/src/components/timeline/timeline-live.ts +77 -0
  116. package/src/components/topology/GroupNode.tsx +9 -2
  117. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  118. package/src/components/topology/K8sResourceNode.tsx +387 -243
  119. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  120. package/src/components/topology/TopologyControls.tsx +3 -1
  121. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  122. package/src/components/topology/TopologyGraph.tsx +149 -90
  123. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  124. package/src/components/topology/TopologySearch.tsx +25 -17
  125. package/src/components/topology/index.ts +2 -0
  126. package/src/components/topology/layout.ts +10 -2
  127. package/src/components/topology/topology.css +0 -92
  128. package/src/components/ui/Badge.tsx +6 -1
  129. package/src/components/ui/CodeViewer.tsx +2 -2
  130. package/src/components/ui/Collapse.tsx +59 -0
  131. package/src/components/ui/ConfirmDialog.tsx +7 -3
  132. package/src/components/ui/DialogPortal.tsx +31 -9
  133. package/src/components/ui/Input.tsx +24 -0
  134. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  135. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  136. package/src/components/ui/PaneLoader.tsx +10 -2
  137. package/src/components/ui/ResourceBar.tsx +68 -25
  138. package/src/components/ui/SearchBox.tsx +18 -4
  139. package/src/components/ui/SearchPillInput.tsx +2 -2
  140. package/src/components/ui/SelectMenu.tsx +81 -0
  141. package/src/components/ui/drawer-components.tsx +48 -32
  142. package/src/components/ui/index.ts +6 -0
  143. package/src/components/ui/severity-tone.ts +11 -4
  144. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  145. package/src/components/workload/WorkloadView.tsx +2706 -475
  146. package/src/components/workload/index.ts +1 -1
  147. package/src/theme/tailwind-theme.css +1 -0
  148. package/src/theme/variables.css +2 -1
  149. package/src/types/core.ts +57 -4
  150. package/src/types/gitops-insights.ts +69 -1
  151. package/src/types/gitops.ts +3 -3
  152. package/src/utils/application-history.test.ts +538 -0
  153. package/src/utils/application-history.ts +257 -0
  154. package/src/utils/application-topology.test.ts +198 -0
  155. package/src/utils/application-topology.ts +261 -0
  156. package/src/utils/applications.test.ts +201 -1
  157. package/src/utils/applications.ts +1182 -393
  158. package/src/utils/index.ts +3 -0
  159. package/src/utils/navigation.test.ts +32 -1
  160. package/src/utils/navigation.ts +70 -0
  161. package/src/utils/resource-hierarchy.test.ts +1024 -1
  162. package/src/utils/resource-hierarchy.ts +925 -267
  163. package/src/utils/resource-icons.ts +2 -0
  164. package/src/utils/topology-neighborhood.test.ts +202 -1
  165. package/src/utils/topology-neighborhood.ts +194 -13
  166. package/src/utils/workflow-execution.test.ts +556 -0
  167. package/src/utils/workflow-execution.ts +814 -0
  168. 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 { compactSource, entryTone, gitopsToSeverity, messageToPhase, normalizeHealthStatus, normalizeSyncStatus } from './insights-helpers'
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
- const cat = c.category
207
- if (cat === 'Synced' || c.health === 'Healthy') healthy++
208
- else if (cat === 'Degraded') degraded++
209
- else if (cat === 'Missing') missing++
210
- else if (cat === 'OutOfSync') outOfSync++
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
- {expanded ? <ChevronDown className="h-3.5 w-3.5 text-theme-text-tertiary" /> : <ChevronRight className="h-3.5 w-3.5 text-theme-text-tertiary" />}
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
- {showRaw ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
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
- {expanded && canExpand && (
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
- export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tree }: GitOpsChangesViewProps) {
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 sync step, hook phase, and wave assignment. The plan and changes
738
- // lists are the same resources from different angles — we render one
739
- // unified list ordered by plan step, with plan metadata folded onto each
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
- // Sort changes by plan order (step) so the list reads top-to-bottom in
747
- // the order the controller will reconcile them. Changes without a plan
748
- // entry land at the end in name order — they're managed resources the
749
- // controller saw but didn't sequence (rare but possible for hook resources
750
- // already completed, or status-only entries). Extras-from-tree land
751
- // after all declared rows.
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
- // Wave grouping: when at least one plan entry declares a wave, we render
761
- // wave headers between rows so multi-wave apps read as the operator
762
- // wrote them. Skip the headers entirely for single-wave / no-wave apps —
763
- // an "always wave 0" label is noise.
764
- const hasAnyWave = plan.some((i) => i.waveSet)
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
- {sortedChanges.length} {sortedChanges.length === 1 ? 'resource' : 'resources'}
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
- {sortedChanges.length > 0 && (
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
- {sortedChanges.length === 0 ? (
838
- <div className="p-4 text-sm text-theme-text-secondary">No managed resources reported by the GitOps controller.</div>
839
- ) : (
840
- <div className="divide-y divide-theme-border">
841
- {sortedChanges.map((change, idx) => {
842
- const planItem = planByRef.get(refKey(change.ref))
843
- const step = planItem?.order
844
- const hook = planItem?.hook
845
- const wave = planItem?.wave
846
- const waveSet = !!planItem?.waveSet
847
- const rowKey = refKey(change.ref)
848
- const focused = focusKey === rowKey
849
- const explanation = !change.syncError && !change.message
850
- ? explainChangeStatus(change.sync, change.health, insight.summary)
851
- : ''
852
- const hasInlineDetail = !!(
853
- (change.drift && change.drift.entries.length > 0) ||
854
- (change.recentEvents && change.recentEvents.length > 0)
855
- )
856
- // Render a wave separator above this row when the wave value
857
- // changed from the previous one. waveSet=false rows under a
858
- // hasAnyWave plan get a "Default wave" header — matches
859
- // how Argo's UI separates explicitly-waved from default.
860
- const prevPlan = idx > 0 ? planByRef.get(refKey(sortedChanges[idx - 1]!.ref)) : undefined
861
- const showWaveHeader = hasAnyWave && (idx === 0 || prevPlan?.wave !== wave || prevPlan?.waveSet !== waveSet)
862
- return (
863
- <Fragment key={`${change.ref.group}/${change.ref.kind}/${change.ref.namespace}/${change.ref.name}`}>
864
- {showWaveHeader && (
865
- <div className="bg-theme-base/50 px-4 py-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
866
- {waveSet ? `Wave ${wave}` : 'Default wave'}
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&apos;t evaluate — so a few fields Argo hides may still appear here.
867
1038
  </div>
868
1039
  )}
869
- <ChangeRow
870
- change={change}
871
- step={step}
872
- hook={hook}
873
- explanation={explanation}
874
- focused={focused}
875
- autoExpand={focused}
876
- hasInlineDetail={hasInlineDetail}
877
- onOpenResource={onOpenResource}
878
- sourceTreeURL={sourceTreeURL}
879
- registerRef={(el) => {
880
- if (el) {
881
- rowRefs.current.set(rowKey, el)
882
- } else {
883
- rowRefs.current.delete(rowKey)
884
- }
885
- }}
886
- />
887
- </Fragment>
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
- const [expanded, setExpanded] = useState(autoExpand && hasInlineDetail)
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 && hasInlineDetail) setExpanded(true)
1026
- }, [autoExpand, hasInlineDetail])
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 (hasInlineDetail) {
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 inline detail to expand OR a
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 = hasInlineDetail || (!isAbsent && !!onOpenResource)
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
- {hasInlineDetail ? (
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
- {expanded && hasInlineDetail && (
1171
- <div className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
1172
- {driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
1173
- {events.length > 0 && <RecentEventsPanel events={events} />}
1174
- </div>
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 (