@skyhook-io/k8s-ui 1.8.11 → 1.8.13

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 (87) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.test.tsx +136 -8
  3. package/src/components/applications/ApplicationDetail.tsx +1215 -487
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  5. package/src/components/gitops/GitOpsTableView.tsx +10 -3
  6. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  7. package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
  8. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  9. package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
  10. package/src/components/gitops/insights/index.ts +1 -0
  11. package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
  12. package/src/components/gitops/insights/insights-helpers.ts +5 -0
  13. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
  14. package/src/components/issues/IssuesView.tsx +55 -29
  15. package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
  16. package/src/components/issues/ResourceIssuesSection.tsx +21 -1
  17. package/src/components/issues/severity.ts +1 -0
  18. package/src/components/resources/ResourcesView.tsx +109 -19
  19. package/src/components/resources/column-filter-serialization.test.ts +79 -1
  20. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  40. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  54. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  55. package/src/components/resources/resource-utils-argo.ts +8 -0
  56. package/src/components/resources/resource-utils.ts +94 -25
  57. package/src/components/shared/ResourceActionsBar.tsx +54 -22
  58. package/src/components/shared/UnifiedDiff.tsx +51 -0
  59. package/src/components/shared/index.ts +1 -0
  60. package/src/components/timeline/TimelineList.tsx +7 -3
  61. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  62. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  63. package/src/components/topology/TopologyControls.tsx +3 -1
  64. package/src/components/topology/TopologyGraph.tsx +76 -90
  65. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  66. package/src/components/topology/TopologySearch.tsx +23 -15
  67. package/src/components/topology/index.ts +2 -0
  68. package/src/components/ui/Badge.tsx +1 -1
  69. package/src/components/ui/SelectMenu.tsx +81 -0
  70. package/src/components/ui/Tooltip.test.tsx +21 -0
  71. package/src/components/ui/Tooltip.tsx +9 -12
  72. package/src/components/ui/drawer-components.tsx +9 -6
  73. package/src/components/ui/index.ts +2 -0
  74. package/src/components/workload/WorkloadView.tsx +24 -3
  75. package/src/theme/tailwind-theme.css +1 -0
  76. package/src/theme/variables.css +1 -0
  77. package/src/types/gitops-insights.ts +69 -1
  78. package/src/types/gitops.ts +3 -3
  79. package/src/utils/api-resources.test.ts +36 -1
  80. package/src/utils/api-resources.ts +16 -0
  81. package/src/utils/application-history.test.ts +538 -0
  82. package/src/utils/application-history.ts +257 -0
  83. package/src/utils/applications.test.ts +34 -0
  84. package/src/utils/applications.ts +25 -0
  85. package/src/utils/index.ts +1 -0
  86. package/src/utils/resource-hierarchy.test.ts +35 -1
  87. package/src/utils/resource-hierarchy.ts +20 -0
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronUp, 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'
@@ -20,6 +20,7 @@ import {
20
20
  entryTone,
21
21
  gitopsToSeverity,
22
22
  healthStatusRank,
23
+ isArgoResourceSyncEligible,
23
24
  messageToPhase,
24
25
  normalizeHealthStatus,
25
26
  normalizeSyncStatus,
@@ -32,6 +33,10 @@ import {
32
33
  interface GitOpsStatusStripProps {
33
34
  insight?: GitOpsInsight | null
34
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
35
40
  }
36
41
 
37
42
  // Status strip carries the operation chip (when a sync is in flight or
@@ -51,7 +56,7 @@ interface GitOpsStatusStripProps {
51
56
  //
52
57
  // Health and Sync badges live next to the title in the page header —
53
58
  // pair them there with identity, not here.
54
- export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps) {
59
+ export function GitOpsStatusStrip({ insight, loading, renderRevisionMeta }: GitOpsStatusStripProps) {
55
60
  const summary = insight?.summary
56
61
  if (loading) {
57
62
  return <div className="h-8 animate-pulse border-b border-theme-border bg-theme-base" />
@@ -135,6 +140,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
135
140
  </Tooltip>
136
141
  )}
137
142
  {reconcileAge && <span className="text-theme-text-tertiary">· {reconcileAge}</span>}
143
+ {revision && renderRevisionMeta?.(revision)}
138
144
  </span>
139
145
  )}
140
146
  {!shortRev && reconcileAge && <MetaFact label="Last reconcile" value={reconcileAge} />}
@@ -223,11 +229,13 @@ function buildHealthSummary(changes: GitOpsChange[]): { text: string; tone: stri
223
229
  let outOfSync = 0
224
230
  let other = 0
225
231
  for (const c of changes) {
226
- const cat = c.category
227
- if (cat === 'Synced' || c.health === 'Healthy') healthy++
228
- else if (cat === 'Degraded') degraded++
229
- else if (cat === 'Missing') missing++
230
- 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++
231
239
  else other++
232
240
  }
233
241
  const total = changes.length
@@ -283,7 +291,7 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
283
291
 
284
292
  function isInFlightPhase(phase: string): boolean {
285
293
  const p = phase.toLowerCase()
286
- return p.includes('running') || p.includes('progress') || p.includes('reconcil')
294
+ return p.includes('running') || p.includes('terminat') || p.includes('progress') || p.includes('reconcil')
287
295
  }
288
296
 
289
297
  // Show the operation chip only for phases the operator needs to *act on*.
@@ -703,6 +711,8 @@ interface GitOpsChangesViewProps {
703
711
  insight?: GitOpsInsight | null
704
712
  error?: Error | null
705
713
  onOpenResource?: (ref: GitOpsChange['ref']) => void
714
+ onSyncResource?: (ref: GitOpsChange['ref']) => void
715
+ syncResourceDisabledReason?: string
706
716
  // When set, the matching change row scrolls into view and gets a transient
707
717
  // highlight ring. Used when the user clicks "View →" on an issue alert in
708
718
  // the band above. Key shape: `${kind}/${namespace||''}/${name}` (group is
@@ -714,6 +724,15 @@ interface GitOpsChangesViewProps {
714
724
  // Argo's default list-view behavior. Default mode still shows declared
715
725
  // resources only — the diagnostic data (drift, events) lives there.
716
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
717
736
  }
718
737
 
719
738
  // Status facets for the Resources list. OutOfSync is a sync-status concern
@@ -724,7 +743,7 @@ const STATUS_FACETS: { key: ResourceStatusFacet; label: string; tone: FilterPill
724
743
  { key: 'missing', label: 'Missing', tone: 'danger' },
725
744
  ]
726
745
 
727
- export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tree }: GitOpsChangesViewProps) {
746
+ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResource, syncResourceDisabledReason, focusKey, tree, renderResourceDiff, onOpenSettings }: GitOpsChangesViewProps) {
728
747
  // "All resources" toggle: when on, render generated descendants alongside
729
748
  // the controller's declared inventory. Argo's UI defaults to "all" — we
730
749
  // default to "declared" because the diagnostic data (drift, events) lives
@@ -741,6 +760,9 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
741
760
  const [statusFilters, setStatusFilters] = useState<Set<ResourceStatusFacet>>(new Set())
742
761
  const [sort, setSort] = useState<{ key: ResourceSortKey; dir: SortDir }>({ key: 'order', dir: 'asc' })
743
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)
744
766
  const plan = insight?.plan ?? []
745
767
  // Synthesize Change rows for generated tree nodes that aren't already in
746
768
  // the declared inventory. These rows carry less diagnostic data — no
@@ -823,6 +845,23 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
823
845
  // doesn't exist), but we CAN point at where they're declared in Git —
824
846
  // which is the most useful thing to do when there's no drawer to open.
825
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'
826
865
  const toggleFacet = (facet: ResourceStatusFacet) => {
827
866
  setStatusFilters((prev) => {
828
867
  const next = new Set(prev)
@@ -887,6 +926,17 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
887
926
  </div>
888
927
  )}
889
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
+ )}
890
940
  {/* Filter/sort toolbar. The status facets are the primary way to
891
941
  answer "which resources are the problem?" now that the issues band
892
942
  no longer restates every OutOfSync resource above. */}
@@ -931,8 +981,11 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
931
981
  {/* Honest disclaimer about diff scope. Neither Argo nor Flux exposes
932
982
  per-resource desired-vs-live diffs on the CRD — they're computed
933
983
  on demand by their respective servers/CLIs, which Radar doesn't
934
- call. */}
935
- {totalCount > 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) && (
936
989
  <div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
937
990
  Radar reads each resource's drift status from the controller. For a line-by-line diff, {insight.summary.tool === 'fluxcd' ? (
938
991
  insight.summary.kind === 'HelmRelease' ? (
@@ -945,6 +998,56 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
945
998
  )}
946
999
  </div>
947
1000
  )}
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.
1038
+ </div>
1039
+ )}
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
+ )}
948
1051
  {totalCount === 0 ? (
949
1052
  <div className="p-4 text-sm text-theme-text-secondary">No managed resources reported by the GitOps controller.</div>
950
1053
  ) : displayChanges.length === 0 ? (
@@ -984,6 +1087,13 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
984
1087
  (change.drift && change.drift.entries.length > 0) ||
985
1088
  (change.recentEvents && change.recentEvents.length > 0)
986
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
987
1097
  // Render a wave separator above this row when the wave value
988
1098
  // changed from the previous one. waveSet=false rows under a
989
1099
  // waved plan get a "Default wave" header — matches how Argo's
@@ -1002,10 +1112,16 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
1002
1112
  change={change}
1003
1113
  hook={hook}
1004
1114
  explanation={explanation}
1115
+ syncUnavailable={syncUnavailable}
1005
1116
  focused={focused}
1006
1117
  autoExpand={focused}
1007
1118
  hasInlineDetail={hasInlineDetail}
1119
+ canFullDiff={canFullDiff}
1120
+ renderResourceDiff={renderResourceDiff}
1008
1121
  onOpenResource={onOpenResource}
1122
+ onSyncResource={onSyncResource}
1123
+ syncResourceDisabledReason={syncResourceDisabledReason}
1124
+ declared={declaredChanges.has(change)}
1009
1125
  sourceTreeURL={sourceTreeURL}
1010
1126
  registerRef={(el) => {
1011
1127
  if (el) {
@@ -1174,10 +1290,16 @@ function ChangeRow({
1174
1290
  change,
1175
1291
  hook,
1176
1292
  explanation,
1293
+ syncUnavailable,
1177
1294
  focused,
1178
1295
  autoExpand,
1179
1296
  hasInlineDetail,
1297
+ canFullDiff,
1298
+ renderResourceDiff,
1180
1299
  onOpenResource,
1300
+ onSyncResource,
1301
+ syncResourceDisabledReason,
1302
+ declared,
1181
1303
  sourceTreeURL,
1182
1304
  registerRef,
1183
1305
  }: {
@@ -1187,10 +1309,20 @@ function ChangeRow({
1187
1309
  // resources that already ran their hook.
1188
1310
  hook: string | undefined
1189
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
1190
1315
  focused: boolean
1191
1316
  autoExpand: boolean
1192
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
1193
1322
  onOpenResource?: (ref: GitOpsChange['ref']) => void
1323
+ onSyncResource?: (ref: GitOpsChange['ref']) => void
1324
+ syncResourceDisabledReason?: string
1325
+ declared: boolean
1194
1326
  // Constructed URL pointing at the source directory in the remote Git
1195
1327
  // host (github / gitlab / bitbucket). Used as the "where this would be
1196
1328
  // declared" affordance on Missing rows, since opening the drawer for a
@@ -1198,12 +1330,15 @@ function ChangeRow({
1198
1330
  sourceTreeURL?: string | null
1199
1331
  registerRef: (el: HTMLDivElement | null) => void
1200
1332
  }) {
1201
- 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)
1202
1337
  // Auto-expand when an issue alert deep-links to this row — the user just
1203
1338
  // clicked the issue, so they want to see the detail immediately.
1204
1339
  useEffect(() => {
1205
- if (autoExpand && hasInlineDetail) setExpanded(true)
1206
- }, [autoExpand, hasInlineDetail])
1340
+ if (autoExpand && expandable) setExpanded(true)
1341
+ }, [autoExpand, expandable])
1207
1342
  const driftEntries = change.drift?.entries ?? []
1208
1343
  const events = change.recentEvents ?? []
1209
1344
  // Missing resources have no live state to drill into — opening the drawer
@@ -1213,17 +1348,29 @@ function ChangeRow({
1213
1348
  // declared source instead of a non-existent live object.
1214
1349
  const isAbsent = change.health === 'Missing' && !change.hasLive
1215
1350
  const handleRowClick = () => {
1216
- if (hasInlineDetail) {
1351
+ if (expandable) {
1217
1352
  setExpanded((v) => !v)
1218
1353
  } else if (!isAbsent && onOpenResource) {
1219
1354
  onOpenResource(change.ref)
1220
1355
  }
1221
1356
  }
1222
- // 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
1223
1358
  // live resource to drill into. Missing rows without inline detail are
1224
1359
  // intentionally non-interactive — there's nowhere useful to go.
1225
- const rowInteractive = hasInlineDetail || (!isAbsent && !!onOpenResource)
1360
+ const rowInteractive = expandable || (!isAbsent && !!onOpenResource)
1226
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
1227
1374
  return (
1228
1375
  <div
1229
1376
  ref={registerRef}
@@ -1247,7 +1394,7 @@ function ChangeRow({
1247
1394
  )}
1248
1395
  >
1249
1396
  <div className="flex items-baseline gap-2">
1250
- {hasInlineDetail ? (
1397
+ {expandable ? (
1251
1398
  <CollapseChevron open={expanded} className="h-3.5 w-3.5 self-center" />
1252
1399
  ) : (
1253
1400
  <span aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
@@ -1297,9 +1444,22 @@ function ChangeRow({
1297
1444
  <div className="ml-[18px] mt-1 text-xs text-theme-text-tertiary">{explanation}</div>
1298
1445
  )}
1299
1446
  </button>
1300
- <div className="self-start"><SyncStatusBadge sync={normalizeSyncStatus(change.sync ?? change.category)} /></div>
1301
- <div className="self-start"><HealthStatusBadge health={normalizeHealthStatus(change.health)} /></div>
1302
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}
1303
1463
  {/* Three affordance states:
1304
1464
  - Live resource (not Missing): "Open <kind> <name> →" opens the
1305
1465
  K8s drawer.
@@ -1338,11 +1498,20 @@ function ChangeRow({
1338
1498
  )}
1339
1499
  </div>
1340
1500
  </div>
1341
- {hasInlineDetail && (
1501
+ {expandable && (
1342
1502
  <Collapse open={expanded} mountLazily>
1343
1503
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
1344
1504
  {driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
1345
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
+ )}
1346
1515
  </div>
1347
1516
  </Collapse>
1348
1517
  )}
@@ -1355,6 +1524,7 @@ function ChangeRow({
1355
1524
  // inline as "old → new". Path is monospace; values are JSON-encoded and
1356
1525
  // pre-wrapped so structured values (objects, arrays) render readably.
1357
1526
  function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
1527
+ const sourceLabel = driftSourceLabel(drift.source)
1358
1528
  return (
1359
1529
  <div>
1360
1530
  <div className="mb-2 flex items-baseline justify-between gap-2">
@@ -1362,6 +1532,7 @@ function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
1362
1532
  <span className="text-[10px] text-theme-text-tertiary">
1363
1533
  desired (Git) → live ·
1364
1534
  {drift.truncated ? ' showing first 50 entries' : ` ${drift.entries.length} field${drift.entries.length === 1 ? '' : 's'}`}
1535
+ {sourceLabel && ` · ${sourceLabel}`}
1365
1536
  </span>
1366
1537
  </div>
1367
1538
  <div className="space-y-1 font-mono text-[11px]">
@@ -1373,6 +1544,15 @@ function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
1373
1544
  )
1374
1545
  }
1375
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
+
1376
1556
  function DriftEntryRow({ entry }: { entry: NonNullable<GitOpsChange['drift']>['entries'][number] }) {
1377
1557
  if (entry.op === 'removed') {
1378
1558
  return (
@@ -4,3 +4,4 @@ export {
4
4
  GitOpsIssuesBand,
5
5
  GitOpsStatusStrip,
6
6
  } from './GitOpsInsightViews'
7
+ export { ArgoResourceDiff } from './ArgoResourceDiff'
@@ -8,6 +8,7 @@ import {
8
8
  entryTone,
9
9
  gitopsToSeverity,
10
10
  healthStatusRank,
11
+ isArgoResourceSyncEligible,
11
12
  messageToPhase,
12
13
  phaseToTone,
13
14
  resourceStatusCounts,
@@ -171,6 +172,21 @@ describe('resourceStatusCounts', () => {
171
172
  })
172
173
  })
173
174
 
175
+ describe('isArgoResourceSyncEligible', () => {
176
+ const outOfSync = change({ ref: { group: 'apps', kind: 'Deployment', namespace: 'demo', name: 'web' }, sync: 'OutOfSync' })
177
+
178
+ it('allows declared OutOfSync and Missing resources', () => {
179
+ expect(isArgoResourceSyncEligible(outOfSync, true)).toBe(true)
180
+ expect(isArgoResourceSyncEligible(change({ ref: { kind: 'Service', name: 'missing' }, health: 'Missing' }), true)).toBe(true)
181
+ })
182
+
183
+ it('excludes generated, hook, and already-synced resources', () => {
184
+ expect(isArgoResourceSyncEligible(outOfSync, false)).toBe(false)
185
+ expect(isArgoResourceSyncEligible(outOfSync, true, 'PreSync')).toBe(false)
186
+ expect(isArgoResourceSyncEligible(change({ ref: { kind: 'Service', name: 'ready' }, sync: 'Synced', health: 'Healthy' }), true)).toBe(false)
187
+ })
188
+ })
189
+
174
190
  describe('syncStatusRank / healthStatusRank', () => {
175
191
  it('ascending sync rank surfaces OutOfSync before Synced', () => {
176
192
  expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Synced'))
@@ -46,6 +46,11 @@ export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
46
46
  return normalizeHealthStatus(change.health)
47
47
  }
48
48
 
49
+ export function isArgoResourceSyncEligible(change: GitOpsChange, declared: boolean, hook?: string): boolean {
50
+ if (!declared || hook || change.hookPhase) return false
51
+ return changeSync(change) === 'OutOfSync' || changeHealth(change) === 'Missing' || change.category === 'Missing'
52
+ }
53
+
49
54
  // Rank sync/health so an ascending sort surfaces the states an operator cares
50
55
  // about first (drift, then failures). Mirrors the GitOps application table's
51
56
  // syncRank/healthRank so the two tables order status the same way.
@@ -658,6 +658,11 @@ function isNodeTerminating(node: GitOpsTreeNode): boolean {
658
658
  // treatment around them (tool icon overlay, "→" affordance, stronger
659
659
  // border) advertises that they're not just leaves.
660
660
  function gitopsToolForNode(node: GitOpsTreeNode): 'argocd' | 'fluxcd' | undefined {
661
+ // The root node IS the resource this detail page is already showing — it is
662
+ // the subject, not a portal to another page. Never give it the portal
663
+ // treatment (tool badge, chevron, click-to-dive ring): clicking the thing
664
+ // you're already viewing would just stack an identical page onto itself.
665
+ if (node.role === 'root') return undefined
661
666
  const t = node.data?.gitopsTool
662
667
  if (t === 'argocd' || t === 'fluxcd') return t
663
668
  return undefined
@@ -132,6 +132,10 @@ export interface IssueRowProps {
132
132
  as?: 'li' | 'div';
133
133
  className?: string;
134
134
  dimmed?: boolean;
135
+ /** Suppress the "Subject" deep-link in the expanded body — set by hosts that
136
+ * embed the row under the very resource that IS the subject (the drawer header
137
+ * already names it), so it doesn't echo back redundantly. */
138
+ hideSubject?: boolean;
135
139
  renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
136
140
  renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
137
141
  renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
@@ -148,6 +152,7 @@ export function IssueRow({
148
152
  as = 'li',
149
153
  className,
150
154
  dimmed,
155
+ hideSubject,
151
156
  renderBadges,
152
157
  renderMeta,
153
158
  renderActions,
@@ -163,6 +168,36 @@ export function IssueRow({
163
168
  const slotCtx = { issue, open };
164
169
  const timing = issueTiming(issue);
165
170
 
171
+ // Severity pill + age + timing chip. Rendered in two positions the container
172
+ // query toggles: inline at the row's right edge on a wide container, and on a
173
+ // line of its own below the resource line once the row is too narrow to hold
174
+ // it beside the title — so the title never has to truncate to make room.
175
+ const metaChips = (wrapperClass: string) => (
176
+ <div className={`items-center gap-3 ${wrapperClass}`}>
177
+ <span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
178
+ {ISSUE_SEVERITY_LABEL[severity]}
179
+ </span>
180
+ {issue.first_seen ? (
181
+ <>
182
+ <Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
183
+ <time
184
+ dateTime={issue.first_seen}
185
+ className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
186
+ >
187
+ <Clock className="h-3 w-3" aria-hidden />
188
+ {formatCompactAge(issue.first_seen)}
189
+ </time>
190
+ </Tooltip>
191
+ {timing ? (
192
+ <Tooltip content={timing.tooltip} delay={200}>
193
+ <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
194
+ </Tooltip>
195
+ ) : null}
196
+ </>
197
+ ) : null}
198
+ </div>
199
+ );
200
+
166
201
  useEffect(() => {
167
202
  if (open) {
168
203
  setRenderDetails(true);
@@ -206,13 +241,13 @@ export function IssueRow({
206
241
  onToggle();
207
242
  }
208
243
  }}
209
- className={`group flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
244
+ className={`group @container/issue flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
210
245
  >
211
246
  <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
212
247
 
213
248
  <div className="flex min-w-0 flex-1 flex-col gap-1">
214
249
  <div className="flex min-w-0 items-baseline gap-2">
215
- <span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
250
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
216
251
  <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
217
252
  {renderBadges?.(slotCtx)}
218
253
  {/* The detector reason rides the title row while COLLAPSED so the
@@ -265,32 +300,16 @@ export function IssueRow({
265
300
  ) : null}
266
301
  {renderMeta?.(slotCtx)}
267
302
  </div>
303
+ {/* Narrow container: the meta chips drop to their own line here rather
304
+ than stealing width from the title on the row above. Hidden once the
305
+ row is wide enough to hold them inline in the right cluster. */}
306
+ {metaChips('flex @2xl/issue:hidden')}
268
307
  </div>
269
308
 
270
- {/* Right cluster — severity pill THEN age, vertically centered as a group. */}
309
+ {/* Right cluster — the always-visible affordances (per-row action +
310
+ chevron) plus, on a wide container, the inline meta chips. */}
271
311
  <div className="flex shrink-0 items-center gap-3">
272
- <span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
273
- {ISSUE_SEVERITY_LABEL[severity]}
274
- </span>
275
- {/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
276
- {issue.first_seen ? (
277
- <>
278
- <Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
279
- <time
280
- dateTime={issue.first_seen}
281
- className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
282
- >
283
- <Clock className="h-3 w-3" aria-hidden />
284
- {formatCompactAge(issue.first_seen)}
285
- </time>
286
- </Tooltip>
287
- {timing ? (
288
- <Tooltip content={timing.tooltip} delay={200}>
289
- <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
290
- </Tooltip>
291
- ) : null}
292
- </>
293
- ) : null}
312
+ {metaChips('hidden @2xl/issue:flex')}
294
313
  {renderActions?.(slotCtx)}
295
314
  <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
296
315
  </div>
@@ -329,7 +348,7 @@ export function IssueRow({
329
348
  </section>
330
349
  ) : null}
331
350
  <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
332
- <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
351
+ <AffectedResources issue={issue} hideSubject={hideSubject} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
333
352
  </div>
334
353
  </div>
335
354
  </div>
@@ -519,11 +538,13 @@ function ageTitle(issue: Issue): string {
519
538
 
520
539
  function AffectedResources({
521
540
  issue,
541
+ hideSubject,
522
542
  resourceHref,
523
543
  onResourceClick,
524
544
  ResourceLinkIcon,
525
545
  }: {
526
546
  issue: Issue;
547
+ hideSubject?: boolean;
527
548
  resourceHref?: (ref: IssueResourceRef) => string;
528
549
  onResourceClick?: (ref: IssueResourceRef) => void;
529
550
  ResourceLinkIcon: ComponentType<{ className?: string }>;
@@ -532,14 +553,19 @@ function AffectedResources({
532
553
  // count is the backend fan-out size (members, subject excluded — see
533
554
  // grouping.go); fall back to the inline member count, not +1.
534
555
  const total = issue.count ?? members.length;
556
+ // With the subject suppressed and no fanned-out members, this section has
557
+ // nothing to show — return null so the divider row above it doesn't render empty.
558
+ if (hideSubject && members.length === 0) return null;
535
559
  return (
536
560
  <section className="flex flex-col gap-3">
537
561
  {/* The subject (the grouped thing — e.g. the Deployment) is always the
538
562
  first deep-link; members (the folded pods) follow. ResourceLine emits
539
563
  an <li>, so it needs a list parent of its own. */}
540
- <ul className="flex flex-col gap-px">
541
- <ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
542
- </ul>
564
+ {!hideSubject && (
565
+ <ul className="flex flex-col gap-px">
566
+ <ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
567
+ </ul>
568
+ )}
543
569
  {members.length > 0 && (
544
570
  <CardSection icon={Layers} label="Affected resources" labelExtra={`· ${total}`}>
545
571
  <ul className="flex flex-col gap-px">