@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.
- package/package.json +3 -3
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
- package/src/components/dock/BottomDock.tsx +24 -17
- package/src/components/dock/DockContext.tsx +39 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
- package/src/components/gitops/insights/index.ts +6 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
- package/src/components/gitops/insights/insights-helpers.ts +99 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
- package/src/components/gitops/tree/index.ts +4 -0
- package/src/components/gitops/tree/tree-helpers.ts +42 -0
- package/src/components/resources/ResourcesView.tsx +71 -18
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -3
- package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
- package/src/components/shared/EditableYamlView.tsx +28 -17
- package/src/components/shared/ManagedByChip.tsx +45 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +3 -3
- package/src/components/topology/TopologyGraph.tsx +3 -2
- package/src/components/ui/Tooltip.tsx +10 -1
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
- package/src/components/workload/WorkloadView.tsx +66 -0
- package/src/hooks/useKeyboardShortcuts.tsx +3 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +19 -2
- package/src/types/gitops-insights.ts +193 -0
- package/src/types/gitops-tree.ts +57 -0
- package/src/types/index.ts +2 -0
- package/src/utils/badge-colors.ts +6 -1
- package/src/utils/format.ts +28 -0
- package/src/utils/gitops-owner.test.ts +136 -0
- package/src/utils/gitops-owner.ts +92 -0
- package/src/utils/gitops-route.test.ts +78 -0
- package/src/utils/gitops-route.ts +104 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.ts +14 -0
- package/src/utils/resource-hierarchy.ts +47 -3
- package/src/utils/yaml.test.ts +101 -0
- 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} <chart></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 <local-manifests></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
|
+
}
|