@skyhook-io/k8s-ui 1.5.13 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +3 -3
  2. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
  3. package/src/components/dock/BottomDock.tsx +24 -17
  4. package/src/components/dock/DockContext.tsx +39 -0
  5. package/src/components/gitops/index.ts +4 -0
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
  7. package/src/components/gitops/insights/index.ts +6 -0
  8. package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
  9. package/src/components/gitops/insights/insights-helpers.ts +99 -0
  10. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
  11. package/src/components/gitops/tree/index.ts +4 -0
  12. package/src/components/gitops/tree/tree-helpers.ts +42 -0
  13. package/src/components/resources/ResourcesView.tsx +71 -18
  14. package/src/components/resources/index.ts +1 -1
  15. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/PodRenderer.tsx +4 -3
  19. package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
  20. package/src/components/shared/EditableYamlView.tsx +28 -17
  21. package/src/components/shared/ManagedByChip.tsx +45 -0
  22. package/src/components/shared/index.ts +1 -0
  23. package/src/components/timeline/TimelineList.tsx +3 -3
  24. package/src/components/topology/TopologyGraph.tsx +3 -2
  25. package/src/components/ui/Tooltip.tsx +10 -1
  26. package/src/components/ui/drawer-components.tsx +1 -1
  27. package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
  28. package/src/components/workload/WorkloadView.tsx +66 -0
  29. package/src/hooks/useKeyboardShortcuts.tsx +3 -2
  30. package/src/index.ts +3 -0
  31. package/src/types/core.ts +19 -2
  32. package/src/types/gitops-insights.ts +193 -0
  33. package/src/types/gitops-tree.ts +57 -0
  34. package/src/types/index.ts +2 -0
  35. package/src/utils/badge-colors.ts +6 -1
  36. package/src/utils/format.ts +28 -0
  37. package/src/utils/gitops-owner.test.ts +136 -0
  38. package/src/utils/gitops-owner.ts +92 -0
  39. package/src/utils/gitops-route.test.ts +78 -0
  40. package/src/utils/gitops-route.ts +104 -0
  41. package/src/utils/index.ts +2 -0
  42. package/src/utils/navigation.ts +14 -0
  43. package/src/utils/resource-hierarchy.ts +47 -3
  44. package/src/utils/yaml.test.ts +101 -0
  45. package/src/utils/yaml.ts +26 -0
@@ -0,0 +1,1456 @@
1
+ import { AlertTriangle, ChevronDown, ChevronRight, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, Trash2 } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
4
+ import type { GitOpsChange, GitOpsHistoryItem, GitOpsInsight, GitOpsInsightRef, GitOpsIssue, GitOpsPlanItem, GitOpsRemediation, GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
5
+ import { HealthStatusBadge, SyncStatusBadge } from '../GitOpsStatusBadge'
6
+ import { SEVERITY_BADGE, SEVERITY_TEXT } from '../../../utils/badge-colors'
7
+ import { formatRelativeAgeTime } from '../../../utils/format'
8
+ import { Tooltip } from '../../ui/Tooltip'
9
+ import { compactSource, entryTone, gitopsToSeverity, messageToPhase, normalizeHealthStatus, normalizeSyncStatus } from './insights-helpers'
10
+
11
+ interface GitOpsStatusStripProps {
12
+ insight?: GitOpsInsight | null
13
+ loading?: boolean
14
+ }
15
+
16
+ // Status strip carries the operation chip (when a sync is in flight or
17
+ // failed) plus reference metadata. The exact field set depends on the
18
+ // resource's lifecycle phase:
19
+ //
20
+ // - Healthy / steady states: Source / Revision / Last reconcile / Sync mode
21
+ // answer "what is this app pointing at and when did it last reconcile".
22
+ // - Terminating: those fields become operationally meaningless (the
23
+ // controller has stopped reconciling and "Sync mode: Auto" is a lie
24
+ // during cleanup). Replace with deletion-relevant facts: pending
25
+ // duration, finalizers, and a hint about which controller owns
26
+ // cleanup. Source/Revision still exist on the resource if the user
27
+ // wants to dig — they're available in the YAML view of the standard
28
+ // resource drawer; promoting them here when they don't apply just
29
+ // creates contradictory state on the page.
30
+ //
31
+ // Health and Sync badges live next to the title in the page header —
32
+ // pair them there with identity, not here.
33
+ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps) {
34
+ const summary = insight?.summary
35
+ if (loading) {
36
+ return <div className="h-8 animate-pulse border-b border-theme-border bg-theme-base" />
37
+ }
38
+ if (!summary) return null
39
+
40
+ if (summary.terminating) {
41
+ return <TerminatingStatusStrip summary={summary} />
42
+ }
43
+
44
+ const operation = liveOperationPhase(summary.operationPhase)
45
+ const revision = summary.lastRevision || summary.targetRevision || ''
46
+ const shortRev = shortRevisionForCommit(revision)
47
+ const commitUrl = revision ? commitURLForRepo(summary.source, revision) : null
48
+ const reconcileAge = formatRelative(summary.lastReconcile)
49
+ const healthSummary = buildHealthSummary(insight.changes ?? [])
50
+ // STUCK belongs with the operation chip — it's a property of the same
51
+ // state ("failed AND won't self-recover"). Co-locating FAILED + STUCK ·
52
+ // RETRIED N× lets the operator scan the whole operational verdict in
53
+ // one glance instead of reading the failure card body to learn whether
54
+ // the controller has given up.
55
+ const operationFailure = (insight.issues ?? []).find(
56
+ (i) => i.severity === 'critical' && i.scope === 'operation' && i.stuck,
57
+ )
58
+ return (
59
+ <div className="border-b border-theme-border bg-theme-base px-4 py-2">
60
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
61
+ {operation && (
62
+ <Tooltip content={`Last sync operation: ${operation}`} delay={200}>
63
+ <span
64
+ // Pulse only while the operation is actively progressing.
65
+ className={clsx(
66
+ 'badge badge-sm font-medium uppercase tracking-wide',
67
+ SEVERITY_BADGE[gitopsToSeverity(operation)],
68
+ isInFlightPhase(operation) && 'animate-pulse',
69
+ )}
70
+ >
71
+ {operation}
72
+ </span>
73
+ </Tooltip>
74
+ )}
75
+ {operationFailure && (
76
+ <Tooltip content="Argo's retry budget is exhausted — the operation won't self-recover, action is required." delay={200}>
77
+ <span className="rounded-sm bg-red-600/90 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white">
78
+ Stuck · retried {operationFailure.retryCount}×
79
+ </span>
80
+ </Tooltip>
81
+ )}
82
+ {/* When a sync is in flight, surface the live progress message inline
83
+ so the operator sees what's happening without opening Activity.
84
+ For *failed* operations the message is intentionally NOT shown
85
+ here — the GitOpsIssuesBand below owns the failure narrative
86
+ (parsed cause, retry count, raw message) so the strip stays a
87
+ calm orientation row instead of duplicating the error three times. */}
88
+ {operation && summary.operationMessage && isInFlightPhase(operation) && (
89
+ <Tooltip content={summary.operationMessage} delay={400} wrapperClassName="min-w-0 max-w-[60ch]">
90
+ <span className="block truncate text-[11px] text-theme-text-secondary">
91
+ {summary.operationMessage}
92
+ </span>
93
+ </Tooltip>
94
+ )}
95
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-theme-text-tertiary">
96
+ {shortRev && (
97
+ <span className="inline-flex items-baseline gap-1">
98
+ <span className="shrink-0">Latest revision:</span>
99
+ {commitUrl ? (
100
+ <Tooltip content={`Open commit ${revision} on the remote`} delay={400}>
101
+ <a
102
+ href={commitUrl}
103
+ target="_blank"
104
+ rel="noopener noreferrer"
105
+ className="font-mono font-medium text-theme-text-primary underline-offset-2 hover:underline"
106
+ >
107
+ {shortRev}
108
+ </a>
109
+ </Tooltip>
110
+ ) : (
111
+ <Tooltip content={revision} delay={400}>
112
+ <span className="font-mono font-medium text-theme-text-primary">{shortRev}</span>
113
+ </Tooltip>
114
+ )}
115
+ {reconcileAge && <span className="text-theme-text-tertiary">· {reconcileAge}</span>}
116
+ </span>
117
+ )}
118
+ {!shortRev && reconcileAge && <MetaFact label="Last reconcile" value={reconcileAge} />}
119
+ {healthSummary && (
120
+ <span className={clsx('inline-flex items-baseline gap-1 font-medium', healthSummary.tone)}>
121
+ {healthSummary.text}
122
+ </span>
123
+ )}
124
+ </div>
125
+ </div>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ // shortRevisionForCommit normalizes a controller-reported revision to a
131
+ // commit-ish short form. Git SHAs collapse to 7 chars; revisions that
132
+ // already look short (tags, semver, "master@sha1:..." truncated) pass
133
+ // through. Returns empty when the input is empty/whitespace.
134
+ function shortRevisionForCommit(revision: string): string {
135
+ const trimmed = revision.trim()
136
+ if (!trimmed) return ''
137
+ // Flux records "master@sha1:9f4969..." — strip the prefix and shorten.
138
+ const sha1Match = trimmed.match(/sha1:([0-9a-f]{7,40})/i)
139
+ if (sha1Match) return sha1Match[1].slice(0, 7)
140
+ // Pure 40-char SHA → 7 chars.
141
+ if (/^[0-9a-f]{40}$/i.test(trimmed)) return trimmed.slice(0, 7)
142
+ return trimmed
143
+ }
144
+
145
+ // gitTreeURL derives a "browse the source directory" URL from a joined
146
+ // summary.source ("repoURL · path · chart") plus a revision. Used when a
147
+ // resource is Missing — we can't show its live state, but we CAN point at
148
+ // where it's declared in Git so the operator can read the source. Returns
149
+ // null when the host isn't recognized; caller hides the affordance then.
150
+ function gitTreeURL(source: string | undefined, revision: string): string | null {
151
+ if (!source) return null
152
+ const parts = source.split(' · ').map((s) => s.trim()).filter(Boolean)
153
+ if (parts.length === 0) return null
154
+ const repoOnly = parts[0]!
155
+ const subPath = parts[1] || ''
156
+ const clean = repoOnly.replace(/\.git$/, '').replace(/\/$/, '')
157
+ let sha = revision || 'HEAD'
158
+ const m = (revision || '').match(/sha1:([0-9a-f]{7,40})/i)
159
+ if (m) sha = m[1]
160
+ const pathSegment = subPath ? `/${encodeURI(subPath)}` : ''
161
+ if (/^https?:\/\/github\.com\//.test(clean)) return `${clean}/tree/${sha}${pathSegment}`
162
+ if (/^https?:\/\/gitlab\.com\//.test(clean)) return `${clean}/-/tree/${sha}${pathSegment}`
163
+ if (/^https?:\/\/bitbucket\.org\//.test(clean)) return `${clean}/src/${sha}${pathSegment}`
164
+ return null
165
+ }
166
+
167
+ // commitURLForRepo derives the remote URL for a commit when we recognize
168
+ // the source host. Returns null for unrecognized hosts (private gitea,
169
+ // self-hosted, etc.) — the caller renders the SHA as plain text in that
170
+ // case rather than a wrong-looking link.
171
+ //
172
+ // `source` arrives joined as "repoURL · path · chart" (see Summary.Source
173
+ // builder server-side). The repo URL is always the first segment; the rest
174
+ // are path/chart suffixes that would confuse the commit URL constructor.
175
+ function commitURLForRepo(source: string | undefined, revision: string): string | null {
176
+ if (!source || !revision) return null
177
+ const repoOnly = source.split(' · ')[0].trim()
178
+ const clean = repoOnly.replace(/\.git$/, '').replace(/\/$/, '')
179
+ // Pull out a usable SHA: full or short SHA; or the sha1:... form Flux uses.
180
+ let sha = revision
181
+ const m = revision.match(/sha1:([0-9a-f]{7,40})/i)
182
+ if (m) sha = m[1]
183
+ if (!/^[0-9a-f]{7,40}$/i.test(sha)) return null
184
+ // Recognized hosts. Self-hosted GitLab/Gitea variants would need the
185
+ // server to surface a `commitUrlTemplate` annotation — out of scope here.
186
+ if (/^https?:\/\/github\.com\//.test(clean)) return `${clean}/commit/${sha}`
187
+ if (/^https?:\/\/gitlab\.com\//.test(clean)) return `${clean}/-/commit/${sha}`
188
+ if (/^https?:\/\/bitbucket\.org\//.test(clean)) return `${clean}/commits/${sha}`
189
+ return null
190
+ }
191
+
192
+ // buildHealthSummary turns the per-resource Changes list into a one-line
193
+ // "did everything come up?" summary. All-healthy reads green and explicit
194
+ // ("4/4 resources healthy"); mixed states list the unhealthy buckets so
195
+ // the user sees at a glance what didn't make it.
196
+ function buildHealthSummary(changes: GitOpsChange[]): { text: string; tone: string } | null {
197
+ if (changes.length === 0) return null
198
+ let healthy = 0
199
+ let degraded = 0
200
+ let missing = 0
201
+ let outOfSync = 0
202
+ let other = 0
203
+ for (const c of changes) {
204
+ const cat = c.category
205
+ if (cat === 'Synced' || c.health === 'Healthy') healthy++
206
+ else if (cat === 'Degraded') degraded++
207
+ else if (cat === 'Missing') missing++
208
+ else if (cat === 'OutOfSync') outOfSync++
209
+ else other++
210
+ }
211
+ const total = changes.length
212
+ if (healthy === total) {
213
+ return { text: `✓ ${total}/${total} resources healthy`, tone: 'text-emerald-500' }
214
+ }
215
+ const parts: string[] = []
216
+ if (degraded > 0) parts.push(`${degraded} Degraded`)
217
+ if (missing > 0) parts.push(`${missing} Missing`)
218
+ if (outOfSync > 0) parts.push(`${outOfSync} OutOfSync`)
219
+ if (healthy > 0) parts.push(`${healthy} healthy`)
220
+ if (other > 0) parts.push(`${other} other`)
221
+ return { text: parts.join(' · '), tone: 'text-amber-500' }
222
+ }
223
+
224
+ // TerminatingStatusStrip swaps the regular metadata for deletion-relevant
225
+ // facts (pending duration, finalizers). Source/Revision move behind a
226
+ // disclosure — still available for forensics but not noise during deletion.
227
+ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsight['summary']> }) {
228
+ const [showHistorical, setShowHistorical] = useState(false)
229
+ const pending = formatRelative(summary.terminationStartedAt) || 'recently'
230
+ const finalizers = summary.finalizers ?? []
231
+ return (
232
+ <div className="border-b border-orange-500/20 bg-orange-500/[0.04] px-4 py-2">
233
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
234
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-theme-text-tertiary">
235
+ <MetaFact label="Pending deletion" value={pending} />
236
+ {finalizers.length > 0 && (
237
+ <MetaFact label="Finalizers" value={finalizers.join(', ')} mono />
238
+ )}
239
+ </div>
240
+ <button
241
+ type="button"
242
+ onClick={() => setShowHistorical((v) => !v)}
243
+ className="shrink-0 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
244
+ >
245
+ {showHistorical ? '− Hide pre-deletion metadata' : '+ Show pre-deletion metadata'}
246
+ </button>
247
+ </div>
248
+ {showHistorical && (
249
+ <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">
250
+ {summary.source && <MetaFact label="Source" value={summary.source} />}
251
+ {(summary.lastRevision || summary.targetRevision) && (
252
+ <MetaFact label="Revision" value={summary.lastRevision || summary.targetRevision || '-'} mono />
253
+ )}
254
+ {summary.lastReconcile && <MetaFact label="Last reconcile" value={formatRelative(summary.lastReconcile)} />}
255
+ {summary.autoSyncMode && <MetaFact label="Sync mode" value={summary.autoSyncMode} />}
256
+ </div>
257
+ )}
258
+ </div>
259
+ )
260
+ }
261
+
262
+ function isInFlightPhase(phase: string): boolean {
263
+ const p = phase.toLowerCase()
264
+ return p.includes('running') || p.includes('progress') || p.includes('reconcil')
265
+ }
266
+
267
+ // Show the operation chip only for phases the operator needs to *act on*.
268
+ // "Succeeded" + "Idle" are calm steady states — surfacing them in always-on
269
+ // chrome adds noise (and reads contradictorily when the app is OutOfSync but
270
+ // the *last* sync technically succeeded). Failure + in-flight phases get
271
+ // surfaced because they imply work happening or stuck.
272
+ function liveOperationPhase(phase?: string): string | null {
273
+ if (!phase) return null
274
+ const p = phase.toLowerCase()
275
+ if (p === 'succeeded' || p === 'idle' || p === '') return null
276
+ return phase
277
+ }
278
+
279
+ function MetaFact({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
280
+ // inline-flex so each fact sizes to content; flex-wrap on the parent
281
+ // handles row breaks. max-w-full guards the pathological "value wider
282
+ // than viewport" case where truncate + tooltip take over.
283
+ return (
284
+ <span className="inline-flex min-w-0 max-w-full items-baseline gap-1">
285
+ <span className="shrink-0">{label}:</span>
286
+ <Tooltip content={value} delay={400} wrapperClassName="min-w-0">
287
+ <span className={clsx('block truncate font-medium text-theme-text-primary', mono && 'font-mono')}>{value}</span>
288
+ </Tooltip>
289
+ </span>
290
+ )
291
+ }
292
+
293
+ // GitOpsIssuesBand renders top-of-page issues with three render paths:
294
+ // lifecycle banner > critical operation failure (GitOpsFailureCard) >
295
+ // stacked alert rows for everything else. When `terminating` is true,
296
+ // the non-lifecycle paths fold into a default-collapsed disclosure since
297
+ // pre-deletion operation failures are forensic-only — the controller
298
+ // stopped reconciling once deletion started.
299
+ export function GitOpsIssuesBand({
300
+ issues,
301
+ onSelectIssue,
302
+ onRemediate,
303
+ remediationPending,
304
+ terminating,
305
+ }: {
306
+ issues?: GitOpsIssue[] | null
307
+ onSelectIssue?: (issue: GitOpsIssue) => void
308
+ onRemediate?: (remediation: GitOpsRemediation) => void
309
+ remediationPending?: boolean
310
+ terminating?: boolean
311
+ }) {
312
+ const list = issues ?? []
313
+ if (list.length === 0) return null
314
+ const lifecycle = list.find((i) => i.scope === 'lifecycle')
315
+ const nonLifecycle = lifecycle ? list.filter((i) => i !== lifecycle) : list
316
+ const operationFailure = nonLifecycle.find((i) => i.severity === 'critical' && i.scope === 'operation')
317
+ const others = operationFailure ? nonLifecycle.filter((i) => i !== operationFailure) : nonLifecycle
318
+ const showHistoricalCollapsed = terminating && (operationFailure || others.length > 0)
319
+ return (
320
+ <div className="border-b border-theme-border">
321
+ {lifecycle && <GitOpsLifecycleBanner issue={lifecycle} />}
322
+ {showHistoricalCollapsed ? (
323
+ <GitOpsHistoricalIssuesDisclosure operationFailure={operationFailure} others={others} onSelectIssue={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />
324
+ ) : (
325
+ <>
326
+ {operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
327
+ {others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
328
+ </>
329
+ )}
330
+ </div>
331
+ )
332
+ }
333
+
334
+ // GitOpsLifecycleBanner promotes the lifecycle Issue (resource pending
335
+ // deletion) above all other issues with a distinct orange treatment that
336
+ // matches the [Terminating] chip in the title row. Nothing else on the
337
+ // page should dominate when the resource is being deleted.
338
+ function GitOpsLifecycleBanner({ issue }: { issue: GitOpsIssue }) {
339
+ return (
340
+ <div className="border-b border-orange-500/30 bg-orange-500/[0.08] px-4 py-3">
341
+ <div className="flex items-start gap-3">
342
+ <Trash2 className="mt-0.5 h-4 w-4 shrink-0 text-orange-400" />
343
+ <div className="min-w-0 flex-1">
344
+ <div className="flex items-baseline gap-2">
345
+ <h3 className="text-sm font-semibold text-orange-300">{issue.reason}</h3>
346
+ <span className="text-[10px] uppercase tracking-wide text-orange-400/70">Lifecycle</span>
347
+ </div>
348
+ <p className="mt-1 text-[13px] text-theme-text-secondary">{issue.message}</p>
349
+ {issue.cause && <p className="mt-1 text-[12px] text-orange-300/90">{issue.cause}</p>}
350
+ {issue.action && <p className="mt-1 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
351
+ </div>
352
+ </div>
353
+ </div>
354
+ )
355
+ }
356
+
357
+ // GitOpsHistoricalIssuesDisclosure wraps the regular issue rendering in a
358
+ // collapsible disclosure when the resource is Terminating. Default
359
+ // collapsed because pre-deletion failures are forensic context, not
360
+ // actionable. Counts the issues so the operator can see at a glance how
361
+ // many were active before deletion was initiated.
362
+ function GitOpsHistoricalIssuesDisclosure({
363
+ operationFailure,
364
+ others,
365
+ onSelectIssue,
366
+ onRemediate,
367
+ remediationPending,
368
+ }: {
369
+ operationFailure?: GitOpsIssue
370
+ others: GitOpsIssue[]
371
+ onSelectIssue?: (issue: GitOpsIssue) => void
372
+ onRemediate?: (remediation: GitOpsRemediation) => void
373
+ remediationPending?: boolean
374
+ }) {
375
+ const [expanded, setExpanded] = useState(false)
376
+ const total = (operationFailure ? 1 : 0) + others.length
377
+ if (total === 0) return null
378
+ return (
379
+ <div className="border-b border-theme-border bg-theme-surface/40">
380
+ <button
381
+ type="button"
382
+ onClick={() => setExpanded((v) => !v)}
383
+ className="flex w-full items-center justify-between px-4 py-2 text-left transition-colors hover:bg-theme-hover/40"
384
+ aria-expanded={expanded}
385
+ >
386
+ <div className="flex items-center gap-2">
387
+ {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" />}
388
+ <span className="text-[12px] font-medium text-theme-text-secondary">
389
+ Pre-deletion issues ({total})
390
+ </span>
391
+ <span className="text-[11px] text-theme-text-tertiary">
392
+ captured before deletion was initiated — forensic context, not actionable
393
+ </span>
394
+ </div>
395
+ </button>
396
+ {expanded && (
397
+ <div className="border-t border-theme-border">
398
+ {operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
399
+ {others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
400
+ </div>
401
+ )}
402
+ </div>
403
+ )
404
+ }
405
+
406
+ // Structured failure card. One unit, owns the failure narrative end-to-end:
407
+ // title (parsed cause when recognized, falls back to raw reason), affected
408
+ // resource, retry posture, raw controller error in a collapsed details.
409
+ function GitOpsFailureCard({
410
+ issue,
411
+ onSelect,
412
+ onRemediate,
413
+ remediationPending,
414
+ }: {
415
+ issue: GitOpsIssue
416
+ onSelect?: (issue: GitOpsIssue) => void
417
+ // onRemediate fires when the contextual fix button is clicked. The host
418
+ // app (web/) dispatches on issue.remediation.kind to run the right
419
+ // mutation (create namespace, etc.). Undefined → button is hidden.
420
+ onRemediate?: (remediation: GitOpsRemediation) => void
421
+ remediationPending?: boolean
422
+ }) {
423
+ const [showRaw, setShowRaw] = useState(false)
424
+ const stuck = !!issue.stuck
425
+ const ref = issue.refs?.[0]
426
+ // Title prioritizes the parsed cause's first sentence. Without parsing we
427
+ // get the bare phase ("Failed") which alone tells the user nothing — fall
428
+ // back to the first sentence of the raw message in that case so something
429
+ // useful is always at title weight.
430
+ const title = issue.cause
431
+ ? firstSentence(issue.cause)
432
+ : firstSentence(issue.message) || issue.reason
433
+ // The body sentence is the parsed cause's full text minus the first
434
+ // sentence (which is in the title), or the rest of the message if we
435
+ // didn't recognize the pattern. Either way the operator gets one
436
+ // meaningful sentence at body weight, not a tempfile path prefix.
437
+ const body = issue.cause ? remainderAfterFirstSentence(issue.cause) : remainderAfterFirstSentence(issue.message)
438
+ return (
439
+ <div
440
+ className={clsx(
441
+ 'border-b border-theme-border px-4 py-3',
442
+ stuck ? 'bg-red-500/15 dark:bg-red-500/15' : 'bg-red-500/[0.06]',
443
+ )}
444
+ >
445
+ <div className="flex items-start gap-3">
446
+ <CircleAlert className={clsx('mt-0.5 h-4 w-4 shrink-0', stuck ? 'text-red-700 dark:text-red-300' : 'text-red-600 dark:text-red-400')} />
447
+ <div className="min-w-0 flex-1">
448
+ <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
449
+ <h3 className={clsx('text-sm font-semibold', stuck ? 'text-red-700 dark:text-red-200' : 'text-red-600 dark:text-red-300')}>{title}</h3>
450
+ {/* Low-key retry count for non-stuck failures so the operator
451
+ knows whether to wait or dig. Stuck failures get the
452
+ prominent FAILED · STUCK chip in the status strip instead. */}
453
+ {!stuck && issue.retryCount && issue.retryCount > 0 && (
454
+ <span className="text-[11px] text-theme-text-tertiary">retried {issue.retryCount}×</span>
455
+ )}
456
+ </div>
457
+ {body && <p className="mt-1 text-[13px] text-theme-text-secondary">{body}</p>}
458
+ {ref && (
459
+ <dl className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]">
460
+ <div className="flex gap-1.5">
461
+ <dt className="text-theme-text-tertiary">Affected</dt>
462
+ <dd className="font-medium text-theme-text-primary">{ref.kind} · <span className="font-mono">{ref.name}</span></dd>
463
+ </div>
464
+ </dl>
465
+ )}
466
+ <div className="mt-2 flex flex-wrap items-center gap-3">
467
+ {/* Remediation button — primary contextual action when the parser
468
+ recognized a fix pattern. Placed first so the operator's eye
469
+ lands on the action, not the diagnostic affordances below. */}
470
+ {issue.remediation && onRemediate && (
471
+ <RemediationButton remediation={issue.remediation} pending={!!remediationPending} onClick={() => onRemediate(issue.remediation!)} />
472
+ )}
473
+ {onSelect && ref && (
474
+ <button
475
+ type="button"
476
+ onClick={() => onSelect(issue)}
477
+ className="inline-flex items-center gap-1 rounded border border-red-500/40 bg-theme-base px-2 py-1 text-[11px] font-medium text-red-700 hover:bg-red-500/10 dark:text-red-300"
478
+ >
479
+ View affected resource <ChevronRight className="h-3 w-3" />
480
+ </button>
481
+ )}
482
+ <button
483
+ type="button"
484
+ onClick={() => setShowRaw((v) => !v)}
485
+ className="inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
486
+ >
487
+ {showRaw ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
488
+ {showRaw ? 'Hide raw controller error' : 'Show raw controller error'}
489
+ </button>
490
+ </div>
491
+ {showRaw && (
492
+ <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">
493
+ {issue.message}
494
+ </pre>
495
+ )}
496
+ </div>
497
+ </div>
498
+ </div>
499
+ )
500
+ }
501
+
502
+ // RemediationButton renders the right copy + icon for a known remediation
503
+ // kind. New kinds added to vocab.RemediationKind must add a case here or
504
+ // the button silently falls through to a generic "Apply suggested fix"
505
+ // label that the operator can't trust — we want every contextual action
506
+ // to read specifically.
507
+ function RemediationButton({
508
+ remediation,
509
+ pending,
510
+ onClick,
511
+ }: {
512
+ remediation: GitOpsRemediation
513
+ pending: boolean
514
+ onClick: () => void
515
+ }) {
516
+ let label = 'Apply suggested fix'
517
+ let Icon: typeof Plus = Plus
518
+ if (remediation.kind === 'create-namespace' && remediation.target) {
519
+ label = `Create namespace ${remediation.target}`
520
+ Icon = Plus
521
+ }
522
+ const button = (
523
+ // Primary-blue, not red: the failure card itself is the "diagnosis red"
524
+ // surface; the action button is *constructive* (creating a namespace,
525
+ // applying a fix). Red on a button reads as destructive ("Terminate",
526
+ // "Delete") and would make operators hesitate before clicking a safe fix.
527
+ <button
528
+ type="button"
529
+ onClick={onClick}
530
+ disabled={pending}
531
+ className="btn-brand inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-semibold shadow-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
532
+ >
533
+ {pending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Icon className="h-3 w-3" />}
534
+ {pending ? 'Applying…' : label}
535
+ </button>
536
+ )
537
+ return remediation.hint ? <Tooltip content={remediation.hint} delay={300}>{button}</Tooltip> : button
538
+ }
539
+
540
+ // Compact stack for non-failure issues. Split out from the failure-card
541
+ // path so the rich card can own the top slot without inheriting the
542
+ // "+N more" expand mechanic.
543
+ //
544
+ // If the headline issue has a resource ref and onSelectIssue is wired,
545
+ // clicking jumps directly to the resource in Changes — the expand
546
+ // affordance is only useful when there's metadata behind the headline.
547
+ function refText(ref: GitOpsInsightRef | undefined): string {
548
+ if (!ref) return ''
549
+ return `${ref.kind} ${ref.name}`
550
+ }
551
+
552
+ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssue[]; onSelectIssue?: (issue: GitOpsIssue) => void }) {
553
+ const [expanded, setExpanded] = useState(false)
554
+ if (issues.length === 0) return null
555
+ const headline = issues[0]!
556
+ const remaining = issues.length - 1
557
+ const tone = severityTone(headline.severity)
558
+ const headlineRef = headline.refs?.[0]
559
+ const headlineActionable = !!(onSelectIssue && headlineRef)
560
+ const canExpand = issues.length > 1
561
+ // Single click target per row: row toggles when there's a stack to expand,
562
+ // otherwise it opens the resource.
563
+ const headlineAction: 'expand' | 'open' | 'none' =
564
+ canExpand ? 'expand' : headlineActionable ? 'open' : 'none'
565
+ return (
566
+ <div className={tone.band}>
567
+ <button
568
+ type="button"
569
+ onClick={() => {
570
+ if (headlineAction === 'expand') setExpanded((v) => !v)
571
+ else if (headlineAction === 'open') onSelectIssue?.(headline)
572
+ }}
573
+ disabled={headlineAction === 'none'}
574
+ className={clsx(
575
+ 'group flex w-full items-center gap-2 px-4 py-2 text-left text-xs transition-colors',
576
+ headlineAction !== 'none' ? 'hover:bg-theme-hover/50' : 'cursor-default',
577
+ )}
578
+ aria-expanded={canExpand ? expanded : undefined}
579
+ >
580
+ {tone.icon}
581
+ <span className={clsx('shrink-0 font-semibold', tone.text)}>{headline.reason}</span>
582
+ <span className="min-w-0 flex-1 truncate text-theme-text-secondary">{headline.message}</span>
583
+ {/* Inline count when there are more issues behind the headline.
584
+ Lightweight text — pairs with the chevron as a single disclosure
585
+ unit instead of a separator-bordered count button. */}
586
+ {remaining > 0 && (
587
+ <span className="shrink-0 text-[11px] text-theme-text-tertiary">
588
+ +{remaining} more
589
+ </span>
590
+ )}
591
+ {/* Open-resource pill: only shown when the row's action IS to open
592
+ (single-issue case). When the row expands, the per-row Open
593
+ pills live inside the expanded section, scoped to each item. */}
594
+ {headlineAction === 'open' && headlineRef && (
595
+ <span className="shrink-0 text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
596
+ Open {refText(headlineRef)} →
597
+ </span>
598
+ )}
599
+ {canExpand && (
600
+ expanded
601
+ ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
602
+ : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
603
+ )}
604
+ </button>
605
+ {expanded && canExpand && (
606
+ <div className="divide-y divide-theme-border border-t border-theme-border bg-theme-base/40">
607
+ {issues.slice(1).map((issue: GitOpsIssue, index: number) => {
608
+ const t = severityTone(issue.severity)
609
+ const ref = issue.refs?.[0]
610
+ const actionable = !!(onSelectIssue && ref)
611
+ return (
612
+ <button
613
+ key={`${issue.reason}-${index}`}
614
+ type="button"
615
+ onClick={() => actionable && onSelectIssue?.(issue)}
616
+ disabled={!actionable}
617
+ className={clsx(
618
+ 'group flex w-full items-start gap-2 px-4 py-2 text-left text-xs transition-colors',
619
+ actionable ? 'hover:bg-theme-hover/50' : 'cursor-default',
620
+ )}
621
+ >
622
+ {t.icon}
623
+ <div className="min-w-0 flex-1">
624
+ <div className="flex items-center gap-2">
625
+ <span className={clsx('font-semibold', t.text)}>{issue.reason}</span>
626
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{issue.scope}</span>
627
+ </div>
628
+ <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
629
+ {issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
630
+ </div>
631
+ {actionable && ref && (
632
+ <span className="shrink-0 self-center text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
633
+ Open {refText(ref)} →
634
+ </span>
635
+ )}
636
+ </button>
637
+ )
638
+ })}
639
+ </div>
640
+ )}
641
+ </div>
642
+ )
643
+ }
644
+
645
+ // firstSentence/remainderAfterFirstSentence split a string at the first
646
+ // sentence boundary so the failure card can render "headline + body" from a
647
+ // single source string. Falls back to the whole string when there's no
648
+ // terminator — better than truncating mid-thought.
649
+ function firstSentence(s: string): string {
650
+ if (!s) return ''
651
+ const i = s.search(/[.!?](\s|$)/)
652
+ if (i < 0) return s.trim()
653
+ return s.slice(0, i + 1).trim()
654
+ }
655
+
656
+ function remainderAfterFirstSentence(s: string): string {
657
+ if (!s) return ''
658
+ const i = s.search(/[.!?](\s|$)/)
659
+ if (i < 0) return ''
660
+ return s.slice(i + 1).trim()
661
+ }
662
+
663
+ // Map an Issue severity to its visual elements via the canonical Severity
664
+ // tokens. The full SEVERITY_BADGE classes are used for the band (theme-aware
665
+ // background + text + border) instead of hand-rolled `bg-red-500/10` literals
666
+ // so dark-mode + the `alert` (orange) intermediate tier work consistently.
667
+ function severityTone(severity: string): { band: string; icon: ReactNode; text: string } {
668
+ const sev = gitopsToSeverity(severity)
669
+ const Icon = sev === 'error' ? CircleAlert : (sev === 'warning' || sev === 'alert') ? AlertTriangle : Info
670
+ return {
671
+ band: SEVERITY_BADGE[sev],
672
+ icon: <Icon className={clsx('h-3.5 w-3.5 shrink-0', SEVERITY_TEXT[sev])} />,
673
+ text: SEVERITY_TEXT[sev],
674
+ }
675
+ }
676
+
677
+ interface GitOpsChangesViewProps {
678
+ insight?: GitOpsInsight | null
679
+ error?: Error | null
680
+ onOpenResource?: (ref: GitOpsChange['ref']) => void
681
+ // When set, the matching change row scrolls into view and gets a transient
682
+ // highlight ring. Used when the user clicks "View →" on an issue alert in
683
+ // the band above. Key shape: `${kind}/${namespace||''}/${name}` (group is
684
+ // intentionally not part of the key — issue refs may not carry it).
685
+ focusKey?: string | null
686
+ // Optional topology tree for the "All resources" toggle. When supplied,
687
+ // generated descendants (Pods, ReplicaSets, etc.) that aren't in the
688
+ // controller's declared inventory can be unioned into the list, matching
689
+ // Argo's default list-view behavior. Default mode still shows declared
690
+ // resources only — the diagnostic data (drift, events) lives there.
691
+ tree?: GitOpsResourceTree | null
692
+ }
693
+
694
+ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tree }: GitOpsChangesViewProps) {
695
+ // "All resources" toggle: when on, render generated descendants alongside
696
+ // the controller's declared inventory. Argo's UI defaults to "all" — we
697
+ // default to "declared" because the diagnostic data (drift, events) lives
698
+ // on declared resources only and the triage flow stays cleaner without
699
+ // 30+ Pod rows in the way. Operators who want the full picture flip it.
700
+ const [showAll, setShowAll] = useState(false)
701
+ const changes = insight?.changes ?? []
702
+ const plan = insight?.plan ?? []
703
+ // Synthesize Change rows for generated tree nodes that aren't already in
704
+ // the declared inventory. These rows carry less diagnostic data — no
705
+ // drift, no recent events, no syncResult — but enough to match Argo's
706
+ // "all resources" mental model: kind/name/namespace + live sync/health.
707
+ const extraFromTree: GitOpsChange[] = showAll && tree
708
+ ? buildTreeExtras(tree.nodes ?? [], changes)
709
+ : []
710
+ // refs[focusKey] holds the DOM node of the row to scroll into view; the
711
+ // map persists across renders so the effect can find the node even when
712
+ // changes re-render (e.g. polling).
713
+ const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map())
714
+ useEffect(() => {
715
+ if (!focusKey) return
716
+ const node = rowRefs.current.get(focusKey)
717
+ if (node) {
718
+ node.scrollIntoView({ behavior: 'smooth', block: 'center' })
719
+ }
720
+ }, [focusKey])
721
+ // Distinguish "still loading" from "fetch failed" so a backend 5xx
722
+ // doesn't render as a stuck "Loading…".
723
+ if (error && !insight) {
724
+ return <InsightErrorState error={error} />
725
+ }
726
+ if (!insight) {
727
+ return <CenteredText>Loading GitOps resources...</CenteredText>
728
+ }
729
+ // Build plan metadata maps keyed by ref so each Change row can advertise
730
+ // its sync step, hook phase, and wave assignment. The plan and changes
731
+ // lists are the same resources from different angles — we render one
732
+ // unified list ordered by plan step, with plan metadata folded onto each
733
+ // change row.
734
+ const planByRef = new Map<string, GitOpsPlanItem>()
735
+ for (const item of plan) {
736
+ const key = refKey(item.ref)
737
+ if (!planByRef.has(key)) planByRef.set(key, item)
738
+ }
739
+ // Sort changes by plan order (step) so the list reads top-to-bottom in
740
+ // the order the controller will reconcile them. Changes without a plan
741
+ // entry land at the end in name order — they're managed resources the
742
+ // controller saw but didn't sequence (rare but possible for hook resources
743
+ // already completed, or status-only entries). Extras-from-tree land
744
+ // after all declared rows.
745
+ const sortedChanges = [...changes, ...extraFromTree].sort((a, b) => {
746
+ const ap = planByRef.get(refKey(a.ref))?.order
747
+ const bp = planByRef.get(refKey(b.ref))?.order
748
+ if (ap == null && bp == null) return refKey(a.ref).localeCompare(refKey(b.ref))
749
+ if (ap == null) return 1
750
+ if (bp == null) return -1
751
+ return ap - bp
752
+ })
753
+ // Wave grouping: when at least one plan entry declares a wave, we render
754
+ // wave headers between rows so multi-wave apps read as the operator
755
+ // wrote them. Skip the headers entirely for single-wave / no-wave apps —
756
+ // an "always wave 0" label is noise.
757
+ const hasAnyWave = plan.some((i) => i.waveSet)
758
+ // Source URL for Missing rows. We can't show their live state (resource
759
+ // doesn't exist), but we CAN point at where they're declared in Git —
760
+ // which is the most useful thing to do when there's no drawer to open.
761
+ const sourceTreeURL = gitTreeURL(insight.summary.source, insight.summary.lastRevision || insight.summary.targetRevision || '')
762
+ return (
763
+ <div className="h-full overflow-auto bg-theme-base p-4">
764
+ <section className="rounded-md border border-theme-border bg-theme-surface">
765
+ <div className="flex items-center justify-between border-b border-theme-border px-4 py-2.5">
766
+ <div className="flex items-center gap-2">
767
+ <GitCommit className="h-4 w-4 text-theme-text-tertiary" />
768
+ <h2 className="text-sm font-semibold text-theme-text-primary">Resources</h2>
769
+ {insight.summary.partialReason && (
770
+ <Tooltip content={insight.summary.partialReason} delay={120}>
771
+ <span className="cursor-help text-theme-text-tertiary hover:text-theme-text-secondary">
772
+ <Info className="h-3.5 w-3.5" />
773
+ </span>
774
+ </Tooltip>
775
+ )}
776
+ <span className="text-[11px] tabular-nums text-theme-text-tertiary">
777
+ {sortedChanges.length} {sortedChanges.length === 1 ? 'resource' : 'resources'}
778
+ </span>
779
+ </div>
780
+ {tree && (
781
+ <div className="flex items-center gap-0.5 rounded-md border border-theme-border bg-theme-base p-0.5 text-[11px]">
782
+ <Tooltip content="Resources the GitOps controller declares — drift and events are computed only for these." delay={300}>
783
+ <button
784
+ type="button"
785
+ onClick={() => setShowAll(false)}
786
+ className={clsx(
787
+ 'rounded px-2 py-0.5 font-medium transition-colors',
788
+ !showAll
789
+ ? 'bg-theme-elevated text-theme-text-primary'
790
+ : 'text-theme-text-secondary hover:text-theme-text-primary',
791
+ )}
792
+ >
793
+ Declared
794
+ </button>
795
+ </Tooltip>
796
+ <Tooltip content="Includes generated descendants like Pods and ReplicaSets (matches Argo's default list)." delay={300}>
797
+ <button
798
+ type="button"
799
+ onClick={() => setShowAll(true)}
800
+ className={clsx(
801
+ 'rounded px-2 py-0.5 font-medium transition-colors',
802
+ showAll
803
+ ? 'bg-theme-elevated text-theme-text-primary'
804
+ : 'text-theme-text-secondary hover:text-theme-text-primary',
805
+ )}
806
+ >
807
+ All
808
+ </button>
809
+ </Tooltip>
810
+ </div>
811
+ )}
812
+ </div>
813
+ {/* Honest disclaimer about diff scope. Neither Argo nor Flux exposes
814
+ per-resource desired-vs-live diffs on the CRD — they're computed
815
+ on demand by their respective servers/CLIs, which Radar doesn't
816
+ call. */}
817
+ {sortedChanges.length > 0 && (
818
+ <div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
819
+ Radar reads each resource's drift status from the controller. For a line-by-line diff, {insight.summary.tool === 'fluxcd' ? (
820
+ insight.summary.kind === 'HelmRelease' ? (
821
+ <>run <code className="rounded bg-theme-elevated px-1 py-0.5 font-mono text-[10px]">helm diff upgrade {insight.summary.name} &lt;chart&gt;</code> (requires the helm-diff plugin).</>
822
+ ) : (
823
+ <>run <code className="rounded bg-theme-elevated px-1 py-0.5 font-mono text-[10px]">flux diff kustomization {insight.summary.name} --path &lt;local-manifests&gt;</code>.</>
824
+ )
825
+ ) : (
826
+ <>use the Argo CD UI or run <code className="rounded bg-theme-elevated px-1 py-0.5 font-mono text-[10px]">argocd app diff {insight.summary.name}</code>.</>
827
+ )}
828
+ </div>
829
+ )}
830
+ {sortedChanges.length === 0 ? (
831
+ <div className="p-4 text-sm text-theme-text-secondary">No managed resources reported by the GitOps controller.</div>
832
+ ) : (
833
+ <div className="divide-y divide-theme-border">
834
+ {sortedChanges.map((change, idx) => {
835
+ const planItem = planByRef.get(refKey(change.ref))
836
+ const step = planItem?.order
837
+ const hook = planItem?.hook
838
+ const wave = planItem?.wave
839
+ const waveSet = !!planItem?.waveSet
840
+ const rowKey = refKey(change.ref)
841
+ const focused = focusKey === rowKey
842
+ const explanation = !change.syncError && !change.message
843
+ ? explainChangeStatus(change.sync, change.health, insight.summary)
844
+ : ''
845
+ const hasInlineDetail = !!(
846
+ (change.drift && change.drift.entries.length > 0) ||
847
+ (change.recentEvents && change.recentEvents.length > 0)
848
+ )
849
+ // Render a wave separator above this row when the wave value
850
+ // changed from the previous one. waveSet=false rows under a
851
+ // hasAnyWave plan get a "Default wave" header — matches
852
+ // how Argo's UI separates explicitly-waved from default.
853
+ const prevPlan = idx > 0 ? planByRef.get(refKey(sortedChanges[idx - 1]!.ref)) : undefined
854
+ const showWaveHeader = hasAnyWave && (idx === 0 || prevPlan?.wave !== wave || prevPlan?.waveSet !== waveSet)
855
+ return (
856
+ <Fragment key={`${change.ref.group}/${change.ref.kind}/${change.ref.namespace}/${change.ref.name}`}>
857
+ {showWaveHeader && (
858
+ <div className="bg-theme-base/50 px-4 py-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
859
+ {waveSet ? `Wave ${wave}` : 'Default wave'}
860
+ </div>
861
+ )}
862
+ <ChangeRow
863
+ change={change}
864
+ step={step}
865
+ hook={hook}
866
+ explanation={explanation}
867
+ focused={focused}
868
+ autoExpand={focused}
869
+ hasInlineDetail={hasInlineDetail}
870
+ onOpenResource={onOpenResource}
871
+ sourceTreeURL={sourceTreeURL}
872
+ registerRef={(el) => {
873
+ if (el) {
874
+ rowRefs.current.set(rowKey, el)
875
+ } else {
876
+ rowRefs.current.delete(rowKey)
877
+ }
878
+ }}
879
+ />
880
+ </Fragment>
881
+ )
882
+ })}
883
+ </div>
884
+ )}
885
+ </section>
886
+ </div>
887
+ )
888
+ }
889
+
890
+ // Keys must match between Plan items and Change items for the cross-ref to
891
+ // work. Group can be omitted in either source, so we don't require it for
892
+ // equality — kind+namespace+name is the practical identifier here.
893
+ // buildTreeExtras turns generated tree nodes into synthetic GitOpsChange rows
894
+ // so the unified Resources list can render them without a parallel row
895
+ // component. The declared set is excluded (already rendered from
896
+ // insight.changes); root + group nodes are skipped — root is the GitOps CR
897
+ // itself (rendered as the page header) and groups are collapse-only fixtures.
898
+ function buildTreeExtras(nodes: GitOpsTreeNode[], declared: GitOpsChange[]): GitOpsChange[] {
899
+ const declaredKeys = new Set(declared.map((c) => refKey(c.ref)))
900
+ const out: GitOpsChange[] = []
901
+ for (const n of nodes) {
902
+ if (n.role === 'root' || n.role === 'group') continue
903
+ // Defensive guard: well-formed trees shouldn't produce empty
904
+ // kind/name nodes, but the TypeScript type allows ""; a row with empty
905
+ // identifiers renders as "Open →" and routes to a broken URL on click.
906
+ if (!n.ref.kind || !n.ref.name) continue
907
+ const key = refKey(n.ref)
908
+ if (declaredKeys.has(key)) continue
909
+ out.push({
910
+ ref: {
911
+ group: n.ref.group,
912
+ kind: n.ref.kind,
913
+ namespace: n.ref.namespace,
914
+ name: n.ref.name,
915
+ },
916
+ // Synthetic category: tree nodes don't carry the controller's per-
917
+ // resource sync category. Default to Unknown — the row renders with
918
+ // the live sync/health badges from the topology, which is the same
919
+ // signal at a different vocabulary.
920
+ category: 'Unknown',
921
+ sync: n.sync,
922
+ health: n.health,
923
+ hasDesired: false,
924
+ hasLive: true,
925
+ partial: true,
926
+ partialNote: 'Generated resource — not directly tracked by the GitOps controller (no drift / event diagnostics).',
927
+ })
928
+ }
929
+ return out
930
+ }
931
+
932
+ function refKey(ref: { kind: string; namespace?: string; name: string }): string {
933
+ return `${ref.kind}/${ref.namespace || ''}/${ref.name}`
934
+ }
935
+
936
+ // explainChangeStatus turns a (sync, health) tuple into a one-sentence
937
+ // explanation that's contextual to the parent app's posture (auto-sync,
938
+ // in-flight operation). Returned only for cases where neither a sync error
939
+ // nor a health message is available — otherwise those carry the truth and
940
+ // this would just add noise. Empty string falls through to no row content.
941
+ //
942
+ // The "what to do" framing is intentional: badges already communicate state
943
+ // ("OutOfSync"); the row should communicate what the operator should do
944
+ // about it. With auto-sync on the answer is usually "wait, Argo will fix
945
+ // it"; with manual mode the answer is "click Sync".
946
+ function explainChangeStatus(
947
+ sync: string | undefined,
948
+ health: string | undefined,
949
+ summary: GitOpsInsight['summary'],
950
+ ): string {
951
+ const isAuto = (summary.autoSyncMode ?? '').toLowerCase().startsWith('auto')
952
+ const phase = (summary.operationPhase ?? '').toLowerCase()
953
+ const inFlight = phase === 'running'
954
+ // When the parent operation has Failed/Errored, "click Sync to force it"
955
+ // misleads — sync has already been tried (and likely retried). The top
956
+ // banner owns the cause; per-row copy should defer to it instead of
957
+ // suggesting an action that won't help.
958
+ const parentFailed = phase === 'failed' || phase === 'error'
959
+ if (sync === 'OutOfSync') {
960
+ if (parentFailed) return 'Sync failed for this resource — see the operation error above for the cause.'
961
+ if (inFlight) return 'Live state differs from Git. A sync is in progress — wait for it to finish.'
962
+ if (isAuto) return 'Live state differs from Git. Auto-sync should reconcile this within a few minutes; click Sync to force it.'
963
+ return 'Live state differs from Git. Click Sync to apply the desired state.'
964
+ }
965
+ if (health === 'Missing' && parentFailed) return 'Resource was not created — see the operation error above for the cause.'
966
+ if (health === 'Degraded') return 'Resource reports an unhealthy state. Open the resource for events and logs.'
967
+ if (health === 'Missing') return 'Declared in Git but not present in the cluster. Sync to create it.'
968
+ if (health === 'Progressing') return 'Resource is mid-rollout (e.g. pods coming up). Should converge shortly.'
969
+ if (health === 'Suspended') return 'Resource is paused (e.g. CronJob suspended, HPA disabled). Intentional unless surprising.'
970
+ return ''
971
+ }
972
+
973
+ // ChangeRow: one resource in the Changes list. Two-zone interaction model:
974
+ // - Whole row click → toggle inline expand (when there's inline detail
975
+ // to show; otherwise it's a no-op so we don't tease an empty panel)
976
+ // - "Open" pill on the right → open the standard resource drawer
977
+ //
978
+ // Inline expand pulls together the two new signals that turn "OutOfSync"
979
+ // from a label into an answer:
980
+ // - Drift: per-field diff between desired (last-applied annotation) and
981
+ // live spec — answers "what's actually different?"
982
+ // - Recent events: ImagePullBackOff/FailedScheduling/etc. — answers
983
+ // "what's the underlying cluster reason?"
984
+ function ChangeRow({
985
+ change,
986
+ step,
987
+ hook,
988
+ explanation,
989
+ focused,
990
+ autoExpand,
991
+ hasInlineDetail,
992
+ onOpenResource,
993
+ sourceTreeURL,
994
+ registerRef,
995
+ }: {
996
+ change: GitOpsChange
997
+ step: number | undefined
998
+ // Hook phase from the plan item (the controller-declared annotation).
999
+ // Falls back to change.hookPhase (the executed phase) for visibility on
1000
+ // resources that already ran their hook.
1001
+ hook: string | undefined
1002
+ explanation: string
1003
+ focused: boolean
1004
+ autoExpand: boolean
1005
+ hasInlineDetail: boolean
1006
+ onOpenResource?: (ref: GitOpsChange['ref']) => void
1007
+ // Constructed URL pointing at the source directory in the remote Git
1008
+ // host (github / gitlab / bitbucket). Used as the "where this would be
1009
+ // declared" affordance on Missing rows, since opening the drawer for a
1010
+ // resource that doesn't exist just shows "Resource not found".
1011
+ sourceTreeURL?: string | null
1012
+ registerRef: (el: HTMLDivElement | null) => void
1013
+ }) {
1014
+ const [expanded, setExpanded] = useState(autoExpand && hasInlineDetail)
1015
+ // Auto-expand when an issue alert deep-links to this row — the user just
1016
+ // clicked the issue, so they want to see the detail immediately.
1017
+ useEffect(() => {
1018
+ if (autoExpand && hasInlineDetail) setExpanded(true)
1019
+ }, [autoExpand, hasInlineDetail])
1020
+ const driftEntries = change.drift?.entries ?? []
1021
+ const events = change.recentEvents ?? []
1022
+ // Missing resources have no live state to drill into — opening the drawer
1023
+ // just shows "Resource not found", which is a wasted click. Treat them
1024
+ // differently: hide the Open pill, suppress the drawer-open click path,
1025
+ // and offer "View in Git →" instead so the operator can read the
1026
+ // declared source instead of a non-existent live object.
1027
+ const isAbsent = change.health === 'Missing' && !change.hasLive
1028
+ const handleRowClick = () => {
1029
+ if (hasInlineDetail) {
1030
+ setExpanded((v) => !v)
1031
+ } else if (!isAbsent && onOpenResource) {
1032
+ onOpenResource(change.ref)
1033
+ }
1034
+ }
1035
+ // Row stays click-affordant when there's inline detail to expand OR a
1036
+ // live resource to drill into. Missing rows without inline detail are
1037
+ // intentionally non-interactive — there's nowhere useful to go.
1038
+ const rowInteractive = hasInlineDetail || (!isAbsent && !!onOpenResource)
1039
+ const hookLabel = change.hookPhase || hook
1040
+ return (
1041
+ <div
1042
+ ref={registerRef}
1043
+ className={clsx(
1044
+ 'group transition-colors',
1045
+ focused && 'bg-amber-500/10 ring-2 ring-inset ring-amber-500/60',
1046
+ )}
1047
+ >
1048
+ {/* Fixed action-column width so the Sync/Health badges line up
1049
+ across rows regardless of how long "Open <Kind> <name> →" is. With
1050
+ an `auto` last column, each row's 1fr column got a different
1051
+ residual width, pushing the badges to inconsistent offsets. */}
1052
+ <div className="grid w-full grid-cols-[minmax(0,1fr)_120px_120px_220px] gap-3 px-4 py-3 text-sm">
1053
+ <button
1054
+ type="button"
1055
+ onClick={handleRowClick}
1056
+ disabled={!rowInteractive}
1057
+ className={clsx(
1058
+ 'min-w-0 text-left',
1059
+ rowInteractive ? 'cursor-pointer hover:text-theme-text-primary' : 'cursor-default',
1060
+ )}
1061
+ >
1062
+ <div className="flex items-baseline gap-2">
1063
+ {hasInlineDetail ? (
1064
+ expanded
1065
+ ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
1066
+ : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
1067
+ ) : (
1068
+ <span aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
1069
+ )}
1070
+ {step !== undefined && (
1071
+ <Tooltip content={`Sync plan step ${step}`} delay={200} wrapperClassName="shrink-0">
1072
+ <span className="rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 font-mono text-[10px] text-theme-text-tertiary">
1073
+ step {step}
1074
+ </span>
1075
+ </Tooltip>
1076
+ )}
1077
+ <div className="min-w-0 truncate font-medium text-theme-text-primary">{change.ref.kind} / {change.ref.name}</div>
1078
+ {hookLabel && (
1079
+ <Tooltip content={`Sync hook: ${hookLabel}`} delay={200} wrapperClassName="shrink-0">
1080
+ <span className="rounded border border-violet-400/40 bg-violet-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-violet-700 dark:text-violet-400">
1081
+ {hookLabel}
1082
+ </span>
1083
+ </Tooltip>
1084
+ )}
1085
+ {/* Detail badges: surface that there's something to see in
1086
+ the expanded panel. Without these the user has no signal
1087
+ that clicking will reveal anything useful. */}
1088
+ {driftEntries.length > 0 && (
1089
+ <Tooltip content={`${driftEntries.length} field${driftEntries.length === 1 ? '' : 's'} differ from Git`} delay={200} wrapperClassName="shrink-0">
1090
+ <span className="rounded border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-400">
1091
+ {driftEntries.length} diff
1092
+ </span>
1093
+ </Tooltip>
1094
+ )}
1095
+ {events.length > 0 && (
1096
+ <Tooltip content={`${events.length} recent event${events.length === 1 ? '' : 's'}`} delay={200} wrapperClassName="shrink-0">
1097
+ <span className={clsx(
1098
+ 'rounded border px-1.5 py-0.5 text-[10px] font-medium',
1099
+ events.some((e) => e.type === 'Warning')
1100
+ ? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-400'
1101
+ : 'border-theme-border bg-theme-elevated text-theme-text-tertiary',
1102
+ )}>
1103
+ {events.length} event{events.length === 1 ? '' : 's'}
1104
+ </span>
1105
+ </Tooltip>
1106
+ )}
1107
+ </div>
1108
+ <div className="ml-[18px] truncate text-xs text-theme-text-tertiary">{change.ref.namespace || '(cluster)'} {change.ref.group ? `· ${change.ref.group}` : ''}</div>
1109
+ {/* Per-resource sync error gets emphasis (red text) over the
1110
+ live health message — operators chasing a broken sync want
1111
+ the failure reason on the same row, not in a drawer. */}
1112
+ {change.syncError && (
1113
+ <Tooltip content={change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
1114
+ <span className="line-clamp-3 text-xs text-red-600 dark:text-red-400">{change.syncError}</span>
1115
+ </Tooltip>
1116
+ )}
1117
+ {change.message && !change.syncError && <div className="ml-[18px] mt-1 line-clamp-2 text-xs text-theme-text-secondary">{change.message}</div>}
1118
+ {!change.syncError && !change.message && explanation && (
1119
+ <div className="ml-[18px] mt-1 text-xs text-theme-text-tertiary">{explanation}</div>
1120
+ )}
1121
+ </button>
1122
+ <div className="self-start"><SyncStatusBadge sync={normalizeSyncStatus(change.sync ?? change.category)} /></div>
1123
+ <div className="self-start"><HealthStatusBadge health={normalizeHealthStatus(change.health)} /></div>
1124
+ <div className="self-start">
1125
+ {/* Three affordance states:
1126
+ - Live resource (not Missing): "Open <kind> <name> →" opens the
1127
+ K8s drawer.
1128
+ - Missing resource WITH a recognized Git host: "View in Git →"
1129
+ opens the source directory in a new tab.
1130
+ - Missing resource without recognized host: nothing — the
1131
+ row's own explanation copy is the surface; we don't fake
1132
+ an action that wouldn't help. */}
1133
+ {!isAbsent && onOpenResource && (
1134
+ <button
1135
+ type="button"
1136
+ onClick={() => onOpenResource(change.ref)}
1137
+ title={`Open ${change.ref.kind} ${change.ref.name}`}
1138
+ className="block w-full truncate rounded border border-theme-border bg-theme-base px-2 py-0.5 text-left text-[11px] text-theme-text-secondary opacity-70 transition-all hover:bg-theme-hover hover:text-theme-text-primary group-hover:opacity-100"
1139
+ >
1140
+ Open {change.ref.kind} {change.ref.name} →
1141
+ </button>
1142
+ )}
1143
+ {isAbsent && sourceTreeURL && (
1144
+ <Tooltip content="Open the source directory in Git — the live resource doesn't exist yet" delay={300}>
1145
+ <a
1146
+ href={sourceTreeURL}
1147
+ target="_blank"
1148
+ rel="noopener noreferrer"
1149
+ onClick={(e) => e.stopPropagation()}
1150
+ className="inline-block rounded border border-theme-border bg-theme-base px-2 py-0.5 text-[11px] text-theme-text-secondary opacity-70 transition-all hover:bg-theme-hover hover:text-theme-text-primary group-hover:opacity-100"
1151
+ >
1152
+ View in Git →
1153
+ </a>
1154
+ </Tooltip>
1155
+ )}
1156
+ {isAbsent && !sourceTreeURL && (
1157
+ <span className="block text-[11px] italic text-theme-text-tertiary">
1158
+ No live resource
1159
+ </span>
1160
+ )}
1161
+ </div>
1162
+ </div>
1163
+ {expanded && hasInlineDetail && (
1164
+ <div className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
1165
+ {driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
1166
+ {events.length > 0 && <RecentEventsPanel events={events} />}
1167
+ </div>
1168
+ )}
1169
+ </div>
1170
+ )
1171
+ }
1172
+
1173
+ // DriftPanel renders the structured per-field diff. Format mimics a
1174
+ // `diff -u` summary: removed paths in red, added in green, changed shown
1175
+ // inline as "old → new". Path is monospace; values are JSON-encoded and
1176
+ // pre-wrapped so structured values (objects, arrays) render readably.
1177
+ function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
1178
+ return (
1179
+ <div>
1180
+ <div className="mb-2 flex items-baseline justify-between gap-2">
1181
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Field diff</h4>
1182
+ <span className="text-[10px] text-theme-text-tertiary">
1183
+ desired (Git) → live ·
1184
+ {drift.truncated ? ' showing first 50 entries' : ` ${drift.entries.length} field${drift.entries.length === 1 ? '' : 's'}`}
1185
+ </span>
1186
+ </div>
1187
+ <div className="space-y-1 font-mono text-[11px]">
1188
+ {drift.entries.map((entry, i) => (
1189
+ <DriftEntryRow key={`${entry.path}-${i}`} entry={entry} />
1190
+ ))}
1191
+ </div>
1192
+ </div>
1193
+ )
1194
+ }
1195
+
1196
+ function DriftEntryRow({ entry }: { entry: NonNullable<GitOpsChange['drift']>['entries'][number] }) {
1197
+ if (entry.op === 'removed') {
1198
+ return (
1199
+ <div>
1200
+ <span className="text-red-600 dark:text-red-400">- {entry.path}</span>
1201
+ {entry.desired && <span className="ml-2 text-theme-text-secondary">{entry.desired}</span>}
1202
+ </div>
1203
+ )
1204
+ }
1205
+ if (entry.op === 'added') {
1206
+ return (
1207
+ <div>
1208
+ <span className="text-emerald-700 dark:text-emerald-400">+ {entry.path}</span>
1209
+ {entry.live && <span className="ml-2 text-theme-text-secondary">{entry.live}</span>}
1210
+ </div>
1211
+ )
1212
+ }
1213
+ return (
1214
+ <div>
1215
+ <span className="text-amber-700 dark:text-amber-400">~ {entry.path}</span>
1216
+ <span className="ml-2 text-theme-text-tertiary">{entry.desired}</span>
1217
+ <span className="mx-1 text-theme-text-tertiary">→</span>
1218
+ <span className="text-theme-text-primary">{entry.live}</span>
1219
+ </div>
1220
+ )
1221
+ }
1222
+
1223
+ // RecentEventsPanel surfaces the last few events involving this resource.
1224
+ // Warning events get a red bar so the eye lands on them; normals are
1225
+ // muted. Aggregation count (when present) is a critical signal — "this
1226
+ // failed 47 times" is very different from "this failed once".
1227
+ function RecentEventsPanel({ events }: { events: NonNullable<GitOpsChange['recentEvents']> }) {
1228
+ return (
1229
+ <div className="mt-3 first:mt-0">
1230
+ <h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Recent events</h4>
1231
+ <div className="space-y-1">
1232
+ {events.map((e, i) => {
1233
+ const isWarning = e.type === 'Warning'
1234
+ return (
1235
+ <div
1236
+ key={`${e.reason}-${e.lastTimestamp}-${i}`}
1237
+ className={clsx(
1238
+ 'rounded border px-2 py-1.5 text-[11px]',
1239
+ isWarning ? 'border-red-500/40 bg-red-500/5' : 'border-theme-border bg-theme-base',
1240
+ )}
1241
+ >
1242
+ <div className="flex items-baseline gap-2">
1243
+ <span className={clsx('font-semibold', isWarning ? 'text-red-700 dark:text-red-400' : 'text-theme-text-primary')}>
1244
+ {e.reason}
1245
+ </span>
1246
+ {e.count && e.count > 1 && (
1247
+ <span className="text-[10px] text-theme-text-tertiary">×{e.count}</span>
1248
+ )}
1249
+ <span className="ml-auto text-[10px] text-theme-text-tertiary">{formatRelativeTime(e.lastTimestamp)}</span>
1250
+ </div>
1251
+ <p className="mt-0.5 text-theme-text-secondary">{e.message}</p>
1252
+ </div>
1253
+ )
1254
+ })}
1255
+ </div>
1256
+ </div>
1257
+ )
1258
+ }
1259
+
1260
+ function formatRelativeTime(value: string): string {
1261
+ return formatRelativeAgeTime(value, '')
1262
+ }
1263
+
1264
+ interface GitOpsActivityInsightViewProps {
1265
+ insight?: GitOpsInsight | null
1266
+ error?: Error | null
1267
+ // Optional rollback callback. When provided AND insight.capabilities.rollback
1268
+ // is true, history rows with an ID expose a Rollback button that fires this
1269
+ // with the target entry. The consumer is responsible for the confirmation
1270
+ // dialog + the actual mutation.
1271
+ onRollback?: (item: GitOpsHistoryItem) => void
1272
+ }
1273
+
1274
+ export function GitOpsActivityInsightView({ insight, error, onRollback }: GitOpsActivityInsightViewProps) {
1275
+ if (error && !insight) return <InsightErrorState error={error} />
1276
+ if (!insight) return <CenteredText>Loading GitOps activity...</CenteredText>
1277
+ const canRollback = !!insight.capabilities?.rollback && !!onRollback
1278
+ // Auto-sync makes rollback futile — the controller would re-sync to HEAD
1279
+ // immediately. Argo's own Web UI disables the button in this state. Detect
1280
+ // by autoSyncMode prefix so "Auto · prune", "Auto · self-heal", etc. all match.
1281
+ const autoSyncBlocksRollback = (insight.summary?.autoSyncMode ?? '').toLowerCase().startsWith('auto')
1282
+ return (
1283
+ <div className="h-full overflow-auto bg-theme-base p-4">
1284
+ {/* History is the only section here. The current operation surfaces as
1285
+ the top history row (phase + message + finishedAt come from
1286
+ operationState). Issues live in GitOpsIssuesBand at page top. */}
1287
+ <section className="rounded-md border border-theme-border bg-theme-surface">
1288
+ <SectionHeader
1289
+ icon={Clock3}
1290
+ title="History"
1291
+ hint={canRollback ? 'Each revision can be rolled back to.' : undefined}
1292
+ />
1293
+ <HistoryRows
1294
+ items={insight.history ?? []}
1295
+ canRollback={canRollback}
1296
+ rollbackBlockedReason={autoSyncBlocksRollback ? 'Auto-sync is enabled. Disable it to enable rollback — otherwise the controller will sync forward to HEAD again.' : undefined}
1297
+ onRollback={onRollback}
1298
+ />
1299
+ </section>
1300
+ </div>
1301
+ )
1302
+ }
1303
+
1304
+ // Vertical timeline; left-gutter dot color encodes outcome at a glance.
1305
+ function HistoryRows({
1306
+ items,
1307
+ canRollback = false,
1308
+ rollbackBlockedReason,
1309
+ onRollback,
1310
+ }: {
1311
+ items: GitOpsHistoryItem[]
1312
+ canRollback?: boolean
1313
+ // When set, the Rollback button renders disabled with this string as the
1314
+ // tooltip explaining why. Null/undefined means rollback is enabled normally.
1315
+ rollbackBlockedReason?: string
1316
+ onRollback?: (item: GitOpsHistoryItem) => void
1317
+ }) {
1318
+ if (items.length === 0) {
1319
+ return (
1320
+ <div className="flex items-center gap-3 px-4 py-6 text-sm text-theme-text-tertiary">
1321
+ <span className="h-2 w-2 rounded-full border border-dashed border-theme-text-tertiary" />
1322
+ <span>No deployments yet.</span>
1323
+ </div>
1324
+ )
1325
+ }
1326
+ return (
1327
+ <ol className="px-4 py-3">
1328
+ {items.map((item, index) => {
1329
+ const tone = entryTone(item)
1330
+ const isLast = index === items.length - 1
1331
+ const sourceDisplay = compactSource(item.source)
1332
+ // Only history entries with a numeric ID can be rolled back to —
1333
+ // the in-flight current operation row has no ID and rolling "back"
1334
+ // to it is meaningless.
1335
+ const showRollback = canRollback && !!item.id && !!onRollback
1336
+ return (
1337
+ // `group` enables the Rollback button's hover-reveal; baseline
1338
+ // opacity-40 keeps it touch-discoverable.
1339
+ <li key={`${item.id}-${item.revision}-${index}`} className="group relative grid grid-cols-[16px_minmax(0,1fr)] gap-3 pb-4 last:pb-0">
1340
+ <div className="relative flex justify-center">
1341
+ {!isLast && <span className="absolute left-1/2 top-3 h-full w-[2px] -translate-x-1/2 bg-theme-text-tertiary/30" />}
1342
+ <Tooltip content={item.phase || tone.inferredFrom || 'unknown'} delay={120}>
1343
+ <span className={clsx('relative mt-1 h-2.5 w-2.5 rounded-full ring-2 ring-theme-surface', tone.dot)} />
1344
+ </Tooltip>
1345
+ </div>
1346
+ <div className="min-w-0 text-sm">
1347
+ <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
1348
+ <span className="font-mono text-xs text-theme-text-primary">{item.revision || item.phase || '-'}</span>
1349
+ <PhaseChip phase={item.phase} message={item.message} />
1350
+ <span className="text-[11px] text-theme-text-tertiary">{formatRelative(item.deployedAt)}</span>
1351
+ {item.initiatedBy && (
1352
+ <span className="text-[11px] text-theme-text-tertiary">by {item.initiatedBy}</span>
1353
+ )}
1354
+ {showRollback && (
1355
+ <Tooltip
1356
+ content={rollbackBlockedReason || `Roll back to revision ${item.revision || `#${item.id}`}`}
1357
+ delay={200}
1358
+ wrapperClassName="ml-auto"
1359
+ >
1360
+ {/* Two visually distinct states:
1361
+ - Enabled: low-emphasis baseline (opacity-40), brightens on row hover
1362
+ - Disabled (auto-sync on): full opacity but desaturated, cursor-not-allowed,
1363
+ no hover affordance — reads unambiguously as "not actionable, here's why" */}
1364
+ <button
1365
+ type="button"
1366
+ onClick={() => !rollbackBlockedReason && onRollback?.(item)}
1367
+ disabled={!!rollbackBlockedReason}
1368
+ aria-disabled={!!rollbackBlockedReason}
1369
+ className={clsx(
1370
+ 'rounded border px-1.5 py-0.5 text-[10px] transition-opacity',
1371
+ rollbackBlockedReason
1372
+ ? 'cursor-not-allowed border-theme-border bg-theme-base text-theme-text-tertiary'
1373
+ : 'border-theme-border bg-theme-elevated text-theme-text-secondary opacity-40 hover:bg-theme-hover hover:text-theme-text-primary hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100'
1374
+ )}
1375
+ >
1376
+ Rollback
1377
+ </button>
1378
+ </Tooltip>
1379
+ )}
1380
+ </div>
1381
+ {sourceDisplay && (
1382
+ <Tooltip content={item.source} delay={400} wrapperClassName="mt-0.5 block max-w-full">
1383
+ <span className="block truncate text-xs text-theme-text-secondary">{sourceDisplay}</span>
1384
+ </Tooltip>
1385
+ )}
1386
+ {item.message && (
1387
+ <div className={clsx('mt-0.5 line-clamp-2 text-[11px]', sourceDisplay ? 'text-theme-text-tertiary' : 'text-theme-text-secondary')}>{item.message}</div>
1388
+ )}
1389
+ </div>
1390
+ </li>
1391
+ )
1392
+ })}
1393
+ </ol>
1394
+ )
1395
+ }
1396
+
1397
+
1398
+ // Outcome chip on each history row. The dot in the gutter encodes outcome by
1399
+ // color, but a textual chip makes the result legible at a glance for users who
1400
+ // don't immediately decode the color palette. Falls back to message-derived
1401
+ // phase when the controller didn't populate phase explicitly (Argo only fills
1402
+ // phase on the most recent revision; older entries lose it without inference).
1403
+ function PhaseChip({ phase, message }: { phase?: string; message?: string }) {
1404
+ const effective = phase || messageToPhase(message)
1405
+ if (!effective) return null
1406
+ const severity = gitopsToSeverity(effective)
1407
+ // Don't render a neutral chip — it adds visual noise without information.
1408
+ if (severity === 'neutral') return null
1409
+ const label = effective.charAt(0).toUpperCase() + effective.slice(1).toLowerCase()
1410
+ return (
1411
+ <span className={clsx('badge-sm', SEVERITY_BADGE[severity])}>{label}</span>
1412
+ )
1413
+ }
1414
+
1415
+ function SectionHeader({ icon: Icon, title, hint }: { icon: typeof GitBranch; title: string; hint?: string }) {
1416
+ return (
1417
+ <div className="flex items-center gap-2 border-b border-theme-border px-4 py-2.5">
1418
+ <Icon className="h-4 w-4 text-theme-text-tertiary" />
1419
+ <h2 className="text-sm font-semibold text-theme-text-primary">{title}</h2>
1420
+ {hint && (
1421
+ <Tooltip content={hint} delay={120}>
1422
+ <span className="cursor-help text-theme-text-tertiary hover:text-theme-text-secondary">
1423
+ <Info className="h-3.5 w-3.5" />
1424
+ </span>
1425
+ </Tooltip>
1426
+ )}
1427
+ </div>
1428
+ )
1429
+ }
1430
+
1431
+ function CenteredText({ children }: { children: ReactNode }) {
1432
+ return <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">{children}</div>
1433
+ }
1434
+
1435
+ // Surfaced when the insights endpoint errors. Without this the subviews
1436
+ // would render their "Loading…" placeholder forever, hiding the failure
1437
+ // from the user and from the operator looking at logs.
1438
+ function InsightErrorState({ error }: { error: Error }) {
1439
+ return (
1440
+ <div className="flex h-full items-start justify-center bg-theme-base p-6">
1441
+ <div className={clsx('max-w-2xl rounded-md p-4 text-sm', SEVERITY_BADGE.error)}>
1442
+ <div className="flex items-start gap-2">
1443
+ <CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
1444
+ <div className="min-w-0">
1445
+ <div className="font-semibold">Failed to load GitOps insights</div>
1446
+ <p className="mt-1 break-words opacity-90">{error.message || 'Unknown error'}</p>
1447
+ </div>
1448
+ </div>
1449
+ </div>
1450
+ </div>
1451
+ )
1452
+ }
1453
+
1454
+ function formatRelative(value?: string) {
1455
+ return formatRelativeAgeTime(value)
1456
+ }