@skyhook-io/k8s-ui 1.6.0 → 1.6.1
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 +2 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +52 -1
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/index.ts +2 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +65 -12
- package/src/components/ui/drawer-components.tsx +8 -20
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/types/core.ts +31 -6
- package/src/utils/badge-colors.ts +25 -0
- package/src/utils/gitops-owner.test.ts +70 -111
- package/src/utils/gitops-owner.ts +37 -74
- package/src/utils/helm-status.test.ts +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"elkjs": "^0.11.1",
|
|
88
88
|
"lucide-react": "^1.12.0",
|
|
89
89
|
"react": "^19.2.6",
|
|
90
|
-
"react-dom": "^19.2.
|
|
90
|
+
"react-dom": "^19.2.6",
|
|
91
91
|
"typescript": "^6.0.2",
|
|
92
92
|
"vitest": "^4.1.5",
|
|
93
93
|
"yaml": "^2.9.0"
|
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { useEffect, type ComponentType, type ReactNode } from 'react'
|
|
2
|
+
import { ChevronDown, ChevronRight, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, RotateCw, Settings, Trash2, XCircle } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
5
|
+
import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
|
|
6
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
7
|
+
import type { GitOpsHealthStatus, GitOpsInsight, GitOpsIssue, GitOpsRemediation, SyncStatus } from '../../types'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Props
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface GitOpsDetailIdentity {
|
|
14
|
+
kind: string // 'applications' | 'kustomizations' | ...
|
|
15
|
+
group: string // 'argoproj.io' | 'kustomize.toolkit.fluxcd.io' | ''
|
|
16
|
+
namespace: string
|
|
17
|
+
name: string
|
|
18
|
+
// toolLabel: "ArgoCD" | "FluxCD" — passed through as a chip; kept as a
|
|
19
|
+
// free string so future tools (Komodor, Argo Workflows, etc.) don't need
|
|
20
|
+
// an enum bump here.
|
|
21
|
+
toolLabel: string
|
|
22
|
+
// kindLabel: human-facing kind (e.g. "Application"). Caller does the
|
|
23
|
+
// lookup against its API resource catalog.
|
|
24
|
+
kindLabel: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GitOpsDetailLineage {
|
|
28
|
+
// Parent in a nested-GitOps chain (app-of-apps, Kustomization-applies-
|
|
29
|
+
// Kustomization). Renders an extra clickable breadcrumb segment.
|
|
30
|
+
kind: string
|
|
31
|
+
namespace: string
|
|
32
|
+
name: string
|
|
33
|
+
group: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GitOpsDetailStatus {
|
|
37
|
+
sync: SyncStatus
|
|
38
|
+
health: GitOpsHealthStatus
|
|
39
|
+
// suspended is the *effective* suspended state (status-derived OR
|
|
40
|
+
// suspended-by-Radar via annotations). Caller resolves the OR.
|
|
41
|
+
suspended: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GitOpsDetailMetadata {
|
|
45
|
+
// The "spec/config" facts under the title. Caller pre-formats each
|
|
46
|
+
// string — formatSourceRepo, formatDestination, etc.
|
|
47
|
+
project?: string
|
|
48
|
+
repository?: string
|
|
49
|
+
path?: string
|
|
50
|
+
chart?: string
|
|
51
|
+
destination?: string
|
|
52
|
+
destinationNamespace?: string
|
|
53
|
+
// autoSyncMode: free-string description from insights.summary
|
|
54
|
+
// (e.g. "auto-sync (prune)"). Renders as a fact when present.
|
|
55
|
+
autoSyncMode?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ArgoActionHandlers {
|
|
59
|
+
onSyncRequested: () => void // opens caller's sync options dialog
|
|
60
|
+
onRefresh: (kind: 'normal' | 'hard') => void
|
|
61
|
+
onTerminate: () => void
|
|
62
|
+
onSuspend: () => void
|
|
63
|
+
onResume: () => void
|
|
64
|
+
// Per-action busy state for spinner rendering. Caller maintains these
|
|
65
|
+
// from React Query mutations or equivalent.
|
|
66
|
+
syncing: boolean
|
|
67
|
+
refreshing: boolean
|
|
68
|
+
// refreshingKind: which refresh is in flight — 'normal' or 'hard'.
|
|
69
|
+
// Drives which button shows the spinner so the caller doesn't have to
|
|
70
|
+
// disambiguate from a single boolean.
|
|
71
|
+
refreshingKind: 'normal' | 'hard'
|
|
72
|
+
terminating: boolean
|
|
73
|
+
suspending: boolean
|
|
74
|
+
resuming: boolean
|
|
75
|
+
// autoSyncEnabled: caller derives from spec.syncPolicy.automated.
|
|
76
|
+
autoSyncEnabled: boolean
|
|
77
|
+
// isRunning: caller derives from status.operationState.phase === 'Running'.
|
|
78
|
+
isRunning: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface FluxActionHandlers {
|
|
82
|
+
onReconcile: () => void
|
|
83
|
+
onSyncWithSource: () => void // only invoked for Kustomization + HelmRelease
|
|
84
|
+
onSuspend: () => void
|
|
85
|
+
onResume: () => void
|
|
86
|
+
reconciling: boolean
|
|
87
|
+
syncingWithSource: boolean
|
|
88
|
+
suspending: boolean
|
|
89
|
+
resuming: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface GitOpsHelmValuesData {
|
|
93
|
+
yaml: string
|
|
94
|
+
keyCount: number
|
|
95
|
+
source: 'flux' | 'argo-object' | 'argo-string' | 'argo-parameters'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type GitOpsDetailTab = 'topology' | 'changes' | 'activity'
|
|
99
|
+
|
|
100
|
+
export interface GitOpsDetailLayoutProps {
|
|
101
|
+
// Identity
|
|
102
|
+
identity: GitOpsDetailIdentity
|
|
103
|
+
// Lineage breadcrumb (parent GitOps CR), null when none.
|
|
104
|
+
parent?: GitOpsDetailLineage | null
|
|
105
|
+
|
|
106
|
+
// Status
|
|
107
|
+
status: GitOpsDetailStatus | null
|
|
108
|
+
// Caller resolves terminating + tooltip text. `actionDisabledTooltip` is
|
|
109
|
+
// what disabled action buttons show; `chipTooltip` is what the
|
|
110
|
+
// [Terminating] chip shows.
|
|
111
|
+
terminating: boolean
|
|
112
|
+
terminatingChipTooltip: string
|
|
113
|
+
terminatingActionTooltip: string
|
|
114
|
+
|
|
115
|
+
// Spec/config row
|
|
116
|
+
detail: GitOpsDetailMetadata
|
|
117
|
+
|
|
118
|
+
// Insights — drives StatusStrip + IssuesBand
|
|
119
|
+
insight: GitOpsInsight | null
|
|
120
|
+
insightLoading: boolean
|
|
121
|
+
// Issue interactions — caller wires onSelectIssue to jump to Changes tab
|
|
122
|
+
// and highlight the affected resource.
|
|
123
|
+
onSelectIssue?: (issue: GitOpsIssue) => void
|
|
124
|
+
// Remediation — caller handles each remediation kind (e.g. create-namespace
|
|
125
|
+
// applies a manifest and triggers a follow-on sync).
|
|
126
|
+
onRemediate?: (remediation: GitOpsRemediation) => void
|
|
127
|
+
remediationPending?: boolean
|
|
128
|
+
|
|
129
|
+
// Helm values disclosure — caller renders the values payload; this prop
|
|
130
|
+
// controls whether the disclosure shows up + its open/closed state.
|
|
131
|
+
helmValues?: GitOpsHelmValuesData | null
|
|
132
|
+
helmValuesOpen?: boolean
|
|
133
|
+
onToggleHelmValues?: () => void
|
|
134
|
+
// helmValuesContent is the rendered YAML viewer (CodeViewer in OSS;
|
|
135
|
+
// hub-web brings its own). Kept as a slot so the layout doesn't depend
|
|
136
|
+
// on the OSS-specific CodeViewer component.
|
|
137
|
+
helmValuesContent?: ReactNode
|
|
138
|
+
|
|
139
|
+
// Action buttons — Argo + Flux. Exactly one applies for any given CR.
|
|
140
|
+
isArgoApp: boolean
|
|
141
|
+
isFlux: boolean
|
|
142
|
+
isFluxWorkload: boolean // Kustomization | HelmRelease — gates the
|
|
143
|
+
// "Sync with source" button
|
|
144
|
+
argo?: ArgoActionHandlers
|
|
145
|
+
flux?: FluxActionHandlers
|
|
146
|
+
|
|
147
|
+
// Tab state
|
|
148
|
+
activeTab: GitOpsDetailTab
|
|
149
|
+
onTabChange: (tab: GitOpsDetailTab) => void
|
|
150
|
+
// Fullscreen mode hides the page chrome (header + status strip + tabs
|
|
151
|
+
// become a single slim title bar) and lets the body region expand to
|
|
152
|
+
// fill the viewport. Caller toggles + tracks state.
|
|
153
|
+
fullscreen: boolean
|
|
154
|
+
onToggleFullscreen: () => void
|
|
155
|
+
// Tab body — caller renders the body for the active tab. The shell
|
|
156
|
+
// provides the tab bar + chrome; the body is fully caller-owned so
|
|
157
|
+
// hub-web and OSS can wire their own data sources without the layout
|
|
158
|
+
// knowing.
|
|
159
|
+
renderTabBody: (ctx: { tab: GitOpsDetailTab; fullscreen: boolean }) => ReactNode
|
|
160
|
+
// Optional: top-right tab-bar accessory (Clear filters + Fullscreen
|
|
161
|
+
// buttons in OSS, can be anything else in hub-web).
|
|
162
|
+
renderTabBarAccessory?: (ctx: { tab: GitOpsDetailTab; fullscreen: boolean }) => ReactNode
|
|
163
|
+
// Optional: counts/summary string in the middle of the tab bar (e.g.
|
|
164
|
+
// OSS's TopologyCounts). Caller decides when to render it.
|
|
165
|
+
renderTabBarCounts?: (ctx: { tab: GitOpsDetailTab }) => ReactNode
|
|
166
|
+
|
|
167
|
+
// Loading / error state for the underlying resource fetch — caller
|
|
168
|
+
// surfaces these on the body region (shell renders a spinner / error
|
|
169
|
+
// banner that replaces the body).
|
|
170
|
+
resourceLoading?: boolean
|
|
171
|
+
resourceError?: Error | null
|
|
172
|
+
|
|
173
|
+
// Navigation
|
|
174
|
+
onNavigateRoot: () => void // back to /gitops list
|
|
175
|
+
onNavigateParent?: () => void // breadcrumb parent click
|
|
176
|
+
// (No keyboard-shortcut prop on the layout — registration is
|
|
177
|
+
// router-aware so callers wire `useRegisterShortcut` or equivalent
|
|
178
|
+
// themselves.)
|
|
179
|
+
|
|
180
|
+
// Fleet context — when true, render a destination cluster chip in the
|
|
181
|
+
// header next to the title. Hub-web sets this to surface cross-cluster
|
|
182
|
+
// lineage; OSS leaves it false so single-cluster Radar's UX is unchanged.
|
|
183
|
+
isFleetContext?: boolean
|
|
184
|
+
destinationCluster?: { id: string; name: string }
|
|
185
|
+
|
|
186
|
+
// Tab-title side effect — sets document.title to "<name> — Radar" while
|
|
187
|
+
// mounted, restores on unmount. Opt-in so hub-web fleet detail can pick
|
|
188
|
+
// its own title format ("<name> — Fleet GitOps").
|
|
189
|
+
manageDocumentTitle?: boolean
|
|
190
|
+
documentTitleSuffix?: string // defaults to " — Radar"
|
|
191
|
+
|
|
192
|
+
// Children slot — for dialogs (SyncOptionsDialog, RollbackDialog, …)
|
|
193
|
+
// that should portal to body. Caller owns dialog state. Children render
|
|
194
|
+
// outside the layout's main flex column.
|
|
195
|
+
children?: ReactNode
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Layout
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
203
|
+
const {
|
|
204
|
+
identity,
|
|
205
|
+
parent,
|
|
206
|
+
status,
|
|
207
|
+
terminating,
|
|
208
|
+
terminatingChipTooltip,
|
|
209
|
+
terminatingActionTooltip,
|
|
210
|
+
detail,
|
|
211
|
+
insight,
|
|
212
|
+
insightLoading,
|
|
213
|
+
onSelectIssue,
|
|
214
|
+
onRemediate,
|
|
215
|
+
remediationPending,
|
|
216
|
+
helmValues,
|
|
217
|
+
helmValuesOpen,
|
|
218
|
+
onToggleHelmValues,
|
|
219
|
+
helmValuesContent,
|
|
220
|
+
isArgoApp,
|
|
221
|
+
isFlux,
|
|
222
|
+
isFluxWorkload,
|
|
223
|
+
argo,
|
|
224
|
+
flux,
|
|
225
|
+
activeTab,
|
|
226
|
+
onTabChange,
|
|
227
|
+
fullscreen,
|
|
228
|
+
onToggleFullscreen,
|
|
229
|
+
renderTabBody,
|
|
230
|
+
renderTabBarAccessory,
|
|
231
|
+
renderTabBarCounts,
|
|
232
|
+
resourceLoading,
|
|
233
|
+
resourceError,
|
|
234
|
+
onNavigateRoot,
|
|
235
|
+
onNavigateParent,
|
|
236
|
+
isFleetContext,
|
|
237
|
+
destinationCluster,
|
|
238
|
+
manageDocumentTitle,
|
|
239
|
+
documentTitleSuffix,
|
|
240
|
+
children,
|
|
241
|
+
} = props
|
|
242
|
+
|
|
243
|
+
// Document title side effect — opt-in so hub-web can take ownership of
|
|
244
|
+
// its own title format.
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (!manageDocumentTitle) return
|
|
247
|
+
const previous = document.title
|
|
248
|
+
const suffix = documentTitleSuffix ?? ' — Radar'
|
|
249
|
+
document.title = `${identity.name}${suffix}`
|
|
250
|
+
return () => { document.title = previous }
|
|
251
|
+
}, [identity.name, manageDocumentTitle, documentTitleSuffix])
|
|
252
|
+
|
|
253
|
+
const effectiveSuspended = status?.suspended ?? false
|
|
254
|
+
// graphFullscreen hides everything chrome-side; the body region expands.
|
|
255
|
+
// Mirrors the OSS GitOpsDetailView shell behavior so the visual chrome
|
|
256
|
+
// class set carries through.
|
|
257
|
+
const bodyShellClass = fullscreen
|
|
258
|
+
? 'fixed inset-0 z-[80] flex min-h-0 min-w-0 flex-col bg-theme-base'
|
|
259
|
+
: 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden'
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-theme-base">
|
|
263
|
+
{!fullscreen && (
|
|
264
|
+
<div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-3">
|
|
265
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
266
|
+
<div className="min-w-0">
|
|
267
|
+
{/* Breadcrumb row */}
|
|
268
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
onClick={onNavigateRoot}
|
|
272
|
+
className="shrink-0 text-xs font-medium text-sky-500 transition-colors hover:text-sky-400"
|
|
273
|
+
>
|
|
274
|
+
GitOps
|
|
275
|
+
</button>
|
|
276
|
+
<span className="shrink-0 text-xs text-theme-text-tertiary">/</span>
|
|
277
|
+
{parent && onNavigateParent && (
|
|
278
|
+
<>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
onClick={onNavigateParent}
|
|
282
|
+
className="shrink-0 truncate max-w-[200px] text-xs font-medium text-sky-500 transition-colors hover:text-sky-400"
|
|
283
|
+
title={`Open parent: ${parent.namespace ? `${parent.namespace}/` : ''}${parent.name}`}
|
|
284
|
+
>
|
|
285
|
+
{parent.namespace ? `${parent.namespace}/` : ''}{parent.name}
|
|
286
|
+
</button>
|
|
287
|
+
<span className="shrink-0 text-xs text-theme-text-tertiary">/</span>
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
290
|
+
<h1 className="min-w-0 truncate text-lg font-semibold text-theme-text-primary">
|
|
291
|
+
{identity.namespace ? `${identity.namespace}/` : ''}{identity.name}
|
|
292
|
+
</h1>
|
|
293
|
+
{/* Status badges suppressed on Terminating — last-observed
|
|
294
|
+
sync/health values become stale the moment deletion is
|
|
295
|
+
initiated; the Terminating chip becomes the sole status
|
|
296
|
+
indicator. */}
|
|
297
|
+
{status && !terminating && (
|
|
298
|
+
<>
|
|
299
|
+
<SyncStatusBadge sync={status.sync} suspended={effectiveSuspended} />
|
|
300
|
+
{!effectiveSuspended && <HealthStatusBadge health={status.health} />}
|
|
301
|
+
</>
|
|
302
|
+
)}
|
|
303
|
+
{terminating && (
|
|
304
|
+
<Tooltip content={terminatingChipTooltip}>
|
|
305
|
+
<span className="badge border border-orange-500/40 bg-orange-500/10 text-orange-400">
|
|
306
|
+
<Trash2 className="h-3 w-3" />
|
|
307
|
+
Terminating
|
|
308
|
+
</span>
|
|
309
|
+
</Tooltip>
|
|
310
|
+
)}
|
|
311
|
+
<span className="inline-flex shrink-0 items-center rounded border border-theme-border bg-theme-hover/50 px-1.5 py-0.5 text-[11px] font-medium text-theme-text-secondary">
|
|
312
|
+
{identity.toolLabel} · {identity.kindLabel}
|
|
313
|
+
</span>
|
|
314
|
+
{isFleetContext && destinationCluster && (
|
|
315
|
+
// Cross-cluster lineage chip — fleet-only. Renders the
|
|
316
|
+
// destination cluster alongside the tool/kind chip so
|
|
317
|
+
// operators see at a glance which cluster the workloads
|
|
318
|
+
// actually live in. Clickable navigation lives one level
|
|
319
|
+
// up (the chip in the fleet list page) so the detail
|
|
320
|
+
// header stays compact.
|
|
321
|
+
<span className="inline-flex shrink-0 items-center gap-1 rounded border border-sky-500/40 bg-sky-500/10 px-1.5 py-0.5 text-[11px] font-medium text-sky-400">
|
|
322
|
+
Deployed to: {destinationCluster.name}
|
|
323
|
+
</span>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
{/* Spec/config row */}
|
|
327
|
+
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-0.5 text-[11px] text-theme-text-tertiary">
|
|
328
|
+
<AppFact label="Project" value={detail.project || '-'} />
|
|
329
|
+
{detail.repository && <AppFact label="Source" value={detail.repository} />}
|
|
330
|
+
{detail.path && <AppFact label="Path" value={detail.path} />}
|
|
331
|
+
{detail.chart && <AppFact label="Chart" value={detail.chart} />}
|
|
332
|
+
<AppFact label="Destination" value={detail.destination || '-'} />
|
|
333
|
+
{detail.autoSyncMode && <AppFact label="Sync mode" value={detail.autoSyncMode} />}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
{/* Action buttons */}
|
|
337
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
338
|
+
{isArgoApp && argo && (
|
|
339
|
+
<>
|
|
340
|
+
<ActionButton
|
|
341
|
+
label="Sync…"
|
|
342
|
+
description="Apply manifests from Git to the cluster. Opens an options dialog (prune, dry-run, revision)."
|
|
343
|
+
icon={RefreshCw}
|
|
344
|
+
loading={argo.syncing}
|
|
345
|
+
onClick={argo.onSyncRequested}
|
|
346
|
+
disabled={effectiveSuspended || terminating}
|
|
347
|
+
disabledReason={terminating ? terminatingActionTooltip : undefined}
|
|
348
|
+
primary
|
|
349
|
+
/>
|
|
350
|
+
<ActionButton
|
|
351
|
+
label="Refresh"
|
|
352
|
+
description="Re-check Git for new commits and recompute sync status. Doesn't apply anything."
|
|
353
|
+
icon={RotateCw}
|
|
354
|
+
loading={argo.refreshing && argo.refreshingKind === 'normal'}
|
|
355
|
+
onClick={() => argo.onRefresh('normal')}
|
|
356
|
+
/>
|
|
357
|
+
<ActionButton
|
|
358
|
+
label="Hard refresh"
|
|
359
|
+
description="Like Refresh, but also bypasses Argo's manifest cache (re-renders Helm/Kustomize)."
|
|
360
|
+
icon={RotateCw}
|
|
361
|
+
loading={argo.refreshing && argo.refreshingKind === 'hard'}
|
|
362
|
+
onClick={() => argo.onRefresh('hard')}
|
|
363
|
+
/>
|
|
364
|
+
{argo.isRunning && (
|
|
365
|
+
<ActionButton
|
|
366
|
+
label="Terminate"
|
|
367
|
+
description="Cancel the in-progress sync operation."
|
|
368
|
+
icon={XCircle}
|
|
369
|
+
loading={argo.terminating}
|
|
370
|
+
onClick={argo.onTerminate}
|
|
371
|
+
danger
|
|
372
|
+
/>
|
|
373
|
+
)}
|
|
374
|
+
{argo.autoSyncEnabled ? (
|
|
375
|
+
<ActionButton
|
|
376
|
+
label="Disable auto-sync"
|
|
377
|
+
description="Stop Argo from automatically syncing Git changes. Manual Sync still works."
|
|
378
|
+
icon={Pause}
|
|
379
|
+
loading={argo.suspending}
|
|
380
|
+
onClick={argo.onSuspend}
|
|
381
|
+
disabled={terminating}
|
|
382
|
+
disabledReason={terminating ? terminatingActionTooltip : undefined}
|
|
383
|
+
/>
|
|
384
|
+
) : (
|
|
385
|
+
<ActionButton
|
|
386
|
+
label="Enable auto-sync"
|
|
387
|
+
description="Re-enable automatic syncing of Git changes to the cluster."
|
|
388
|
+
icon={Play}
|
|
389
|
+
loading={argo.resuming}
|
|
390
|
+
onClick={argo.onResume}
|
|
391
|
+
disabled={terminating}
|
|
392
|
+
disabledReason={terminating ? terminatingActionTooltip : undefined}
|
|
393
|
+
/>
|
|
394
|
+
)}
|
|
395
|
+
</>
|
|
396
|
+
)}
|
|
397
|
+
{isFlux && flux && (
|
|
398
|
+
<>
|
|
399
|
+
<ActionButton
|
|
400
|
+
label="Reconcile"
|
|
401
|
+
description="Tell Flux to reconcile this resource now: re-read its source, re-apply the manifests, update status. Skips waiting for the regular reconciliation interval."
|
|
402
|
+
icon={RefreshCw}
|
|
403
|
+
loading={flux.reconciling}
|
|
404
|
+
onClick={flux.onReconcile}
|
|
405
|
+
disabled={effectiveSuspended || terminating}
|
|
406
|
+
disabledReason={terminating ? terminatingActionTooltip : undefined}
|
|
407
|
+
primary
|
|
408
|
+
/>
|
|
409
|
+
{isFluxWorkload && (
|
|
410
|
+
<ActionButton
|
|
411
|
+
label="Sync with source"
|
|
412
|
+
description="Reconcile the upstream source CR (GitRepository/HelmRepository) first — re-fetching from Git or Helm — then reconcile this resource against the refreshed source."
|
|
413
|
+
icon={GitCommit}
|
|
414
|
+
loading={flux.syncingWithSource}
|
|
415
|
+
onClick={flux.onSyncWithSource}
|
|
416
|
+
disabled={terminating}
|
|
417
|
+
disabledReason={terminating ? terminatingActionTooltip : undefined}
|
|
418
|
+
/>
|
|
419
|
+
)}
|
|
420
|
+
{effectiveSuspended ? (
|
|
421
|
+
<ActionButton
|
|
422
|
+
label="Resume"
|
|
423
|
+
description="Resume Flux reconciliation. Flux will start applying changes from the source again on its normal interval."
|
|
424
|
+
icon={Play}
|
|
425
|
+
loading={flux.resuming}
|
|
426
|
+
onClick={flux.onResume}
|
|
427
|
+
disabled={terminating}
|
|
428
|
+
disabledReason={terminating ? terminatingActionTooltip : undefined}
|
|
429
|
+
/>
|
|
430
|
+
) : (
|
|
431
|
+
<ActionButton
|
|
432
|
+
label="Suspend"
|
|
433
|
+
description="Pause Flux reconciliation. The resource stays exactly as-is — new commits in the source won't be applied until you resume."
|
|
434
|
+
icon={Pause}
|
|
435
|
+
loading={flux.suspending}
|
|
436
|
+
onClick={flux.onSuspend}
|
|
437
|
+
disabled={terminating}
|
|
438
|
+
disabledReason={terminating ? terminatingActionTooltip : undefined}
|
|
439
|
+
/>
|
|
440
|
+
)}
|
|
441
|
+
</>
|
|
442
|
+
)}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
{!fullscreen && (
|
|
448
|
+
<>
|
|
449
|
+
<GitOpsStatusStrip insight={insight ?? undefined} loading={insightLoading} />
|
|
450
|
+
<GitOpsIssuesBand
|
|
451
|
+
issues={insight?.issues}
|
|
452
|
+
terminating={terminating}
|
|
453
|
+
onSelectIssue={onSelectIssue}
|
|
454
|
+
remediationPending={remediationPending}
|
|
455
|
+
onRemediate={onRemediate}
|
|
456
|
+
/>
|
|
457
|
+
{helmValues && onToggleHelmValues && (
|
|
458
|
+
<div className="shrink-0 border-b border-theme-border bg-theme-base">
|
|
459
|
+
<button
|
|
460
|
+
type="button"
|
|
461
|
+
onClick={onToggleHelmValues}
|
|
462
|
+
className="flex w-full items-center gap-2 px-4 py-2 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
|
|
463
|
+
aria-expanded={helmValuesOpen}
|
|
464
|
+
>
|
|
465
|
+
{helmValuesOpen ? (
|
|
466
|
+
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
|
467
|
+
) : (
|
|
468
|
+
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
|
469
|
+
)}
|
|
470
|
+
<Settings className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
471
|
+
<span className="font-medium text-theme-text-primary">Helm values</span>
|
|
472
|
+
<span className="tabular-nums text-theme-text-tertiary">
|
|
473
|
+
{helmValues.keyCount} {helmValues.keyCount === 1 ? 'key' : 'keys'}
|
|
474
|
+
</span>
|
|
475
|
+
{helmValues.source === 'argo-parameters' && (
|
|
476
|
+
<span className="text-[10px] uppercase tracking-wider text-theme-text-tertiary">parameters</span>
|
|
477
|
+
)}
|
|
478
|
+
</button>
|
|
479
|
+
{helmValuesOpen && helmValuesContent && (
|
|
480
|
+
<div className="border-t border-theme-border bg-theme-surface px-4 py-3">
|
|
481
|
+
{helmValuesContent}
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
)}
|
|
486
|
+
</>
|
|
487
|
+
)}
|
|
488
|
+
|
|
489
|
+
{resourceLoading ? (
|
|
490
|
+
<div className="flex flex-1 items-center justify-center text-theme-text-secondary">
|
|
491
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading GitOps resource…
|
|
492
|
+
</div>
|
|
493
|
+
) : resourceError ? (
|
|
494
|
+
<div className="p-4 text-sm text-red-500">Failed to load resource: {resourceError.message}</div>
|
|
495
|
+
) : (
|
|
496
|
+
<div className={bodyShellClass}>
|
|
497
|
+
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-4 py-2">
|
|
498
|
+
<div className="flex items-center gap-1 rounded-md border border-theme-border bg-theme-surface p-1">
|
|
499
|
+
<ViewButton active={activeTab === 'topology'} icon={GitBranch} label="Topology" onClick={() => onTabChange('topology')} />
|
|
500
|
+
<ViewButton active={activeTab === 'changes'} icon={GitCommit} label="Resources" onClick={() => onTabChange('changes')} />
|
|
501
|
+
<ViewButton active={activeTab === 'activity'} icon={Clock3} label="Activity" onClick={() => onTabChange('activity')} />
|
|
502
|
+
</div>
|
|
503
|
+
{fullscreen ? (
|
|
504
|
+
<div className="min-w-0 flex-1 truncate text-sm font-medium text-theme-text-primary">
|
|
505
|
+
{identity.namespace ? `${identity.namespace}/` : ''}{identity.name}
|
|
506
|
+
</div>
|
|
507
|
+
) : (
|
|
508
|
+
renderTabBarCounts?.({ tab: activeTab })
|
|
509
|
+
)}
|
|
510
|
+
<div className="flex items-center gap-2">
|
|
511
|
+
{renderTabBarAccessory?.({ tab: activeTab, fullscreen })}
|
|
512
|
+
{activeTab === 'topology' && (
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
onClick={onToggleFullscreen}
|
|
516
|
+
className="rounded-md border border-theme-border bg-theme-surface px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
517
|
+
>
|
|
518
|
+
{fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
519
|
+
</button>
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
{renderTabBody({ tab: activeTab, fullscreen })}
|
|
525
|
+
</div>
|
|
526
|
+
)}
|
|
527
|
+
{children}
|
|
528
|
+
</div>
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// Small subcomponents kept private to keep the public surface narrow.
|
|
534
|
+
// AppFact, ActionButton, ViewButton are tightly coupled to the layout's
|
|
535
|
+
// visual language; if a caller needs them standalone we can export later.
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
function AppFact({ label, value }: { label: string; value: string }) {
|
|
539
|
+
// inline-flex sizes each fact to its content; flex-wrap on the parent
|
|
540
|
+
// handles row breaks. max-w-full + truncate guard against single facts
|
|
541
|
+
// wider than the screen (very long destination URLs).
|
|
542
|
+
return (
|
|
543
|
+
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1">
|
|
544
|
+
<span className="shrink-0 text-theme-text-tertiary">{label}:</span>
|
|
545
|
+
<Tooltip content={value} delay={400} wrapperClassName="min-w-0">
|
|
546
|
+
<span className="block truncate text-theme-text-primary">{value}</span>
|
|
547
|
+
</Tooltip>
|
|
548
|
+
</span>
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function ViewButton({
|
|
553
|
+
active,
|
|
554
|
+
icon: Icon,
|
|
555
|
+
label,
|
|
556
|
+
onClick,
|
|
557
|
+
}: {
|
|
558
|
+
active: boolean
|
|
559
|
+
icon: ComponentType<{ className?: string }>
|
|
560
|
+
label: string
|
|
561
|
+
onClick: () => void
|
|
562
|
+
}) {
|
|
563
|
+
return (
|
|
564
|
+
<button
|
|
565
|
+
type="button"
|
|
566
|
+
onClick={onClick}
|
|
567
|
+
className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
568
|
+
active
|
|
569
|
+
? 'bg-skyhook-500 text-white'
|
|
570
|
+
: 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
571
|
+
}`}
|
|
572
|
+
>
|
|
573
|
+
<Icon className="h-3.5 w-3.5" />
|
|
574
|
+
{label}
|
|
575
|
+
</button>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function ActionButton({
|
|
580
|
+
label,
|
|
581
|
+
description,
|
|
582
|
+
icon: Icon,
|
|
583
|
+
loading,
|
|
584
|
+
disabled,
|
|
585
|
+
disabledReason,
|
|
586
|
+
danger,
|
|
587
|
+
primary,
|
|
588
|
+
onClick,
|
|
589
|
+
}: {
|
|
590
|
+
label: string
|
|
591
|
+
description?: string
|
|
592
|
+
icon: ComponentType<{ className?: string }>
|
|
593
|
+
loading?: boolean
|
|
594
|
+
disabled?: boolean
|
|
595
|
+
disabledReason?: string
|
|
596
|
+
danger?: boolean
|
|
597
|
+
primary?: boolean
|
|
598
|
+
onClick: () => void
|
|
599
|
+
}) {
|
|
600
|
+
// primary → brand fill (one per page); danger → red (destructive);
|
|
601
|
+
// default → bordered ghost on theme surface (secondary actions).
|
|
602
|
+
const variantClass = primary
|
|
603
|
+
? 'btn-brand'
|
|
604
|
+
: danger
|
|
605
|
+
? 'border border-red-500/40 bg-red-500/10 text-red-500 hover:bg-red-500/20'
|
|
606
|
+
: 'border border-theme-border bg-theme-surface text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
607
|
+
const tooltip = disabled && disabledReason ? disabledReason : (description || label)
|
|
608
|
+
return (
|
|
609
|
+
<Tooltip content={tooltip}>
|
|
610
|
+
<button
|
|
611
|
+
type="button"
|
|
612
|
+
onClick={onClick}
|
|
613
|
+
disabled={loading || disabled}
|
|
614
|
+
className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${variantClass}`}
|
|
615
|
+
>
|
|
616
|
+
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Icon className="h-3.5 w-3.5" />}
|
|
617
|
+
{label}
|
|
618
|
+
</button>
|
|
619
|
+
</Tooltip>
|
|
620
|
+
)
|
|
621
|
+
}
|