@skyhook-io/radar-app 1.8.1 → 1.8.3
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 +1 -1
- package/src/App.tsx +167 -56
- package/src/RadarApp.tsx +18 -1
- package/src/api/client.ts +173 -6
- package/src/components/NamespaceSwitcher.tsx +52 -30
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +1 -10
- package/src/components/helm/HelmReleaseDrawer.tsx +802 -44
- package/src/components/helm/HelmView.tsx +85 -16
- package/src/components/helm/ManifestDiffViewer.tsx +15 -4
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +50 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +10 -10
- package/src/components/nav/PrimaryNavRail.tsx +1 -1
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourcesView.tsx +42 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/ui/command-items.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +52 -7
- package/src/context/ConnectionContext.tsx +29 -2
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
|
-
import { FetchResult, useDockReservedHeight } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { FetchResult, useDockReservedHeight, compareVersions } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
|
|
5
5
|
import { TRANSITION_DRAWER } from '../../utils/animation'
|
|
6
6
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
7
|
-
import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch } from 'lucide-react'
|
|
7
|
+
import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch, AlertTriangle, RotateCcw, Clock, GitCompare, Plus, Minus, Equal } from 'lucide-react'
|
|
8
8
|
import { useNavigate } from 'react-router-dom'
|
|
9
9
|
import { clsx } from 'clsx'
|
|
10
|
-
import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmUpgradeInfo, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
|
|
10
|
+
import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmValuesDiff, useHelmNotesDiff, useHelmResourceDiff, useHelmUpgradeInfo, useHelmReleaseVersions, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
|
|
11
11
|
import { useQueryClient } from '@tanstack/react-query'
|
|
12
12
|
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
13
13
|
import { Tooltip } from '../ui/Tooltip'
|
|
14
14
|
import { Markdown } from '../ui/Markdown'
|
|
15
|
-
import type { SelectedHelmRelease, HelmHook, ChartDependency } from '../../types'
|
|
15
|
+
import type { SelectedHelmRelease, HelmHook, ChartDependency, HelmOperation, HelmRevision, ResourceDiff, HookDiagnostic, HookLogEvidence } from '../../types'
|
|
16
16
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
17
17
|
import { formatDate } from './helm-utils'
|
|
18
18
|
import { getHelmStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
@@ -23,6 +23,7 @@ import { ManifestViewer } from './ManifestViewer'
|
|
|
23
23
|
import { ValuesViewer } from './ValuesViewer'
|
|
24
24
|
import { OwnedResources } from './OwnedResources'
|
|
25
25
|
import { ManifestDiffViewer } from './ManifestDiffViewer'
|
|
26
|
+
import { TrackChartSourceDialog } from './TrackChartSourceDialog'
|
|
26
27
|
|
|
27
28
|
interface HelmReleaseDrawerProps {
|
|
28
29
|
release: SelectedHelmRelease
|
|
@@ -33,6 +34,7 @@ interface HelmReleaseDrawerProps {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
type TabId = 'overview' | 'history' | 'manifest' | 'values' | 'resources' | 'hooks' | 'diff'
|
|
37
|
+
type CompareMode = 'summary' | 'values' | 'manifest' | 'notes' | 'resources'
|
|
36
38
|
|
|
37
39
|
const MIN_WIDTH = 500
|
|
38
40
|
const MAX_WIDTH_PERCENT = 0.8
|
|
@@ -43,13 +45,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
43
45
|
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
|
44
46
|
const [copied, setCopied] = useState<string | null>(null)
|
|
45
47
|
const [drawerWidth, setDrawerWidth] = useState(DEFAULT_WIDTH)
|
|
48
|
+
const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? DEFAULT_WIDTH : window.innerWidth))
|
|
46
49
|
const [isResizing, setIsResizing] = useState(false)
|
|
47
50
|
const [selectedRevision, setSelectedRevision] = useState<number | undefined>(undefined)
|
|
48
51
|
const [showAllValues, setShowAllValues] = useState(false)
|
|
49
52
|
const [diffRevisions, setDiffRevisions] = useState<{ rev1: number; rev2: number } | null>(null)
|
|
53
|
+
const [compareMode, setCompareMode] = useState<CompareMode>('summary')
|
|
50
54
|
const [rollbackRevision, setRollbackRevision] = useState<number | null>(null)
|
|
51
55
|
const [showUninstallConfirm, setShowUninstallConfirm] = useState(false)
|
|
52
56
|
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false)
|
|
57
|
+
const [showTrackSource, setShowTrackSource] = useState(false)
|
|
58
|
+
const [selectedVersion, setSelectedVersion] = useState<string | null>(null)
|
|
53
59
|
const resizeStartX = useRef(0)
|
|
54
60
|
const resizeStartWidth = useRef(DEFAULT_WIDTH)
|
|
55
61
|
const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
|
|
@@ -81,6 +87,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
81
87
|
release.name,
|
|
82
88
|
showAllValues,
|
|
83
89
|
canViewSensitive,
|
|
90
|
+
selectedRevision,
|
|
84
91
|
)
|
|
85
92
|
|
|
86
93
|
// Fetch diff if comparing revisions
|
|
@@ -89,7 +96,29 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
89
96
|
release.name,
|
|
90
97
|
diffRevisions?.rev1 || 0,
|
|
91
98
|
diffRevisions?.rev2 || 0,
|
|
92
|
-
canViewSensitive,
|
|
99
|
+
canViewSensitive && compareMode === 'manifest',
|
|
100
|
+
)
|
|
101
|
+
const { data: valuesDiffData, isLoading: valuesDiffLoading } = useHelmValuesDiff(
|
|
102
|
+
helmNamespace,
|
|
103
|
+
release.name,
|
|
104
|
+
diffRevisions?.rev1 || 0,
|
|
105
|
+
diffRevisions?.rev2 || 0,
|
|
106
|
+
false,
|
|
107
|
+
canViewSensitive && compareMode === 'values',
|
|
108
|
+
)
|
|
109
|
+
const { data: notesDiffData, isLoading: notesDiffLoading } = useHelmNotesDiff(
|
|
110
|
+
helmNamespace,
|
|
111
|
+
release.name,
|
|
112
|
+
diffRevisions?.rev1 || 0,
|
|
113
|
+
diffRevisions?.rev2 || 0,
|
|
114
|
+
canViewSensitive && compareMode === 'notes',
|
|
115
|
+
)
|
|
116
|
+
const { data: resourceDiffData, isLoading: resourceDiffLoading } = useHelmResourceDiff(
|
|
117
|
+
helmNamespace,
|
|
118
|
+
release.name,
|
|
119
|
+
diffRevisions?.rev1 || 0,
|
|
120
|
+
diffRevisions?.rev2 || 0,
|
|
121
|
+
canViewSensitive && compareMode === 'resources',
|
|
93
122
|
)
|
|
94
123
|
|
|
95
124
|
// Lazy check for upgrade availability
|
|
@@ -99,6 +128,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
99
128
|
)
|
|
100
129
|
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade check failed'
|
|
101
130
|
|
|
131
|
+
// Available versions for the upgrade dialog's picker — only fetched while the
|
|
132
|
+
// confirm dialog is open. Default the selection to latest when it opens.
|
|
133
|
+
const { data: availableVersions } = useHelmReleaseVersions(helmNamespace, release.name, showUpgradeConfirm)
|
|
134
|
+
const targetVersion = selectedVersion ?? upgradeInfo?.latestVersion ?? ''
|
|
135
|
+
// Semver compare, not list-position: the installed version may be older than
|
|
136
|
+
// the newest-N versions the picker shows, so it isn't always in the list.
|
|
137
|
+
const isDowngrade = Boolean(
|
|
138
|
+
targetVersion && upgradeInfo?.currentVersion &&
|
|
139
|
+
compareVersions(targetVersion, upgradeInfo.currentVersion) === -1
|
|
140
|
+
)
|
|
141
|
+
|
|
102
142
|
// Mutations for actions
|
|
103
143
|
const uninstallMutation = useHelmUninstall()
|
|
104
144
|
const queryClient = useQueryClient()
|
|
@@ -116,13 +156,24 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
116
156
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
117
157
|
}, [onClose])
|
|
118
158
|
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const handleWindowResize = () => setViewportWidth(window.innerWidth)
|
|
161
|
+
handleWindowResize()
|
|
162
|
+
window.addEventListener('resize', handleWindowResize)
|
|
163
|
+
return () => window.removeEventListener('resize', handleWindowResize)
|
|
164
|
+
}, [])
|
|
165
|
+
|
|
166
|
+
const minDrawerWidth = Math.min(MIN_WIDTH, viewportWidth)
|
|
167
|
+
const maxDrawerWidth = viewportWidth < MIN_WIDTH ? viewportWidth : Math.max(MIN_WIDTH, viewportWidth * MAX_WIDTH_PERCENT)
|
|
168
|
+
const renderedDrawerWidth = Math.max(minDrawerWidth, Math.min(drawerWidth, maxDrawerWidth))
|
|
169
|
+
|
|
119
170
|
// Resize handlers
|
|
120
171
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
121
172
|
e.preventDefault()
|
|
122
173
|
setIsResizing(true)
|
|
123
174
|
resizeStartX.current = e.clientX
|
|
124
|
-
resizeStartWidth.current =
|
|
125
|
-
}, [
|
|
175
|
+
resizeStartWidth.current = renderedDrawerWidth
|
|
176
|
+
}, [renderedDrawerWidth])
|
|
126
177
|
|
|
127
178
|
useEffect(() => {
|
|
128
179
|
if (!isResizing) return
|
|
@@ -130,11 +181,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
130
181
|
document.body.style.cursor = 'ew-resize'
|
|
131
182
|
document.body.style.userSelect = 'none'
|
|
132
183
|
|
|
133
|
-
const maxWidth = window.innerWidth * MAX_WIDTH_PERCENT
|
|
134
184
|
const handleMouseMove = (e: MouseEvent) => {
|
|
135
185
|
const deltaX = resizeStartX.current - e.clientX
|
|
136
186
|
const newWidth = resizeStartWidth.current + deltaX
|
|
137
|
-
|
|
187
|
+
const viewport = window.innerWidth
|
|
188
|
+
const minWidth = Math.min(MIN_WIDTH, viewport)
|
|
189
|
+
const maxWidth = viewport < MIN_WIDTH ? viewport : Math.max(MIN_WIDTH, viewport * MAX_WIDTH_PERCENT)
|
|
190
|
+
setDrawerWidth(Math.max(minWidth, Math.min(newWidth, maxWidth)))
|
|
138
191
|
}
|
|
139
192
|
const handleMouseUp = () => setIsResizing(false)
|
|
140
193
|
document.addEventListener('mousemove', handleMouseMove)
|
|
@@ -162,6 +215,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
162
215
|
|
|
163
216
|
const handleCompareRevisions = (rev1: number, rev2: number) => {
|
|
164
217
|
setDiffRevisions({ rev1, rev2 })
|
|
218
|
+
setCompareMode('summary')
|
|
165
219
|
switchTab('diff')
|
|
166
220
|
}
|
|
167
221
|
|
|
@@ -234,7 +288,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
234
288
|
}
|
|
235
289
|
|
|
236
290
|
const handleUpgradeConfirm = async () => {
|
|
237
|
-
if (!
|
|
291
|
+
if (!targetVersion) return
|
|
238
292
|
setIsUpgrading(true)
|
|
239
293
|
setUpgradeProgress([])
|
|
240
294
|
|
|
@@ -242,8 +296,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
242
296
|
await upgradeWithProgress(
|
|
243
297
|
helmNamespace,
|
|
244
298
|
release.name,
|
|
245
|
-
|
|
246
|
-
upgradeInfo
|
|
299
|
+
targetVersion,
|
|
300
|
+
upgradeInfo?.repositoryName,
|
|
247
301
|
(event) => {
|
|
248
302
|
if (event.type === 'progress' && event.message) {
|
|
249
303
|
setUpgradeProgress(prev => [...prev, {
|
|
@@ -256,7 +310,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
256
310
|
|
|
257
311
|
setUpgradeProgress(prev => [...prev, {
|
|
258
312
|
phase: 'complete',
|
|
259
|
-
message: `Successfully upgraded to ${
|
|
313
|
+
message: `Successfully upgraded to ${targetVersion}`,
|
|
260
314
|
}])
|
|
261
315
|
|
|
262
316
|
// Invalidate queries
|
|
@@ -268,6 +322,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
268
322
|
setTimeout(() => {
|
|
269
323
|
setShowUpgradeConfirm(false)
|
|
270
324
|
setUpgradeProgress([])
|
|
325
|
+
setSelectedVersion(null)
|
|
271
326
|
refetch()
|
|
272
327
|
switchTab('resources')
|
|
273
328
|
}, 1500)
|
|
@@ -295,7 +350,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
295
350
|
|
|
296
351
|
// Add diff tab only when comparing
|
|
297
352
|
if (diffRevisions) {
|
|
298
|
-
tabs.push({ id: 'diff', label: '
|
|
353
|
+
tabs.push({ id: 'diff', label: 'Compare', icon: GitCompare })
|
|
299
354
|
}
|
|
300
355
|
|
|
301
356
|
return (
|
|
@@ -305,7 +360,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
305
360
|
TRANSITION_DRAWER,
|
|
306
361
|
isOpen ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
|
307
362
|
)}
|
|
308
|
-
style={{ width:
|
|
363
|
+
style={{ width: renderedDrawerWidth, top: headerHeight, height: `calc(100vh - ${headerHeight}px - ${dockInset}px)` }}
|
|
309
364
|
>
|
|
310
365
|
{/* Resize handle */}
|
|
311
366
|
<div
|
|
@@ -342,8 +397,18 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
342
397
|
upgrade check failed
|
|
343
398
|
</span>
|
|
344
399
|
</Tooltip>
|
|
400
|
+
) : upgradeInfo?.updateAvailable && releaseDetail?.managedByFluxHelmRelease ? (
|
|
401
|
+
// Route-only for GitOps-managed releases: a direct `helm upgrade`
|
|
402
|
+
// would be reverted at the next reconcile, so surface the available
|
|
403
|
+
// version as info and point at GitOps rather than offer the upgrade.
|
|
404
|
+
<Tooltip content={`${upgradeInfo.latestVersion} available — managed by Flux, upgrade via the GitOps view (a direct upgrade would be reverted at the next reconcile).`}>
|
|
405
|
+
<span className={clsx('badge', SEVERITY_BADGE.warning, 'opacity-90')}>
|
|
406
|
+
<ArrowUpCircle className="w-3 h-3" />
|
|
407
|
+
{upgradeInfo.latestVersion}
|
|
408
|
+
</span>
|
|
409
|
+
</Tooltip>
|
|
345
410
|
) : upgradeInfo?.updateAvailable ? (
|
|
346
|
-
<Tooltip content={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : ''}` : helmActReason}>
|
|
411
|
+
<Tooltip content={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : upgradeInfo.sourceType === 'oci' ? ' (OCI)' : ''}` : helmActReason}>
|
|
347
412
|
<button
|
|
348
413
|
onClick={() => setShowUpgradeConfirm(true)}
|
|
349
414
|
disabled={!canHelmWrite}
|
|
@@ -363,13 +428,39 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
363
428
|
</span>
|
|
364
429
|
</Tooltip>
|
|
365
430
|
) : upgradeInfo?.error ? (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
431
|
+
releaseDetail?.managedByFluxHelmRelease ? (
|
|
432
|
+
// Managed by Flux — the "Managed by Flux" badge routes to GitOps,
|
|
433
|
+
// where the chart source lives. Don't push a Helm source here.
|
|
434
|
+
<Tooltip content="Chart source is managed by Flux — track upgrades from the GitOps view.">
|
|
435
|
+
<span className="badge bg-theme-hover/50 text-theme-text-secondary">
|
|
436
|
+
source via GitOps
|
|
437
|
+
</span>
|
|
438
|
+
</Tooltip>
|
|
439
|
+
) : upgradeInfo.untracked ? (
|
|
440
|
+
// Helm doesn't record the install source. Offer to register one so
|
|
441
|
+
// Radar can track upgrades for the user's own (e.g. OCI) charts.
|
|
442
|
+
<Tooltip content={canHelmWrite ? "Radar can't tell where this chart was installed from. Register your chart source to track upgrades." : upgradeInfo.error}>
|
|
443
|
+
<button
|
|
444
|
+
onClick={() => canHelmWrite && setShowTrackSource(true)}
|
|
445
|
+
disabled={!canHelmWrite}
|
|
446
|
+
className={clsx(
|
|
447
|
+
'badge transition-colors disabled:pointer-events-none bg-theme-hover/50 text-theme-text-secondary',
|
|
448
|
+
canHelmWrite ? 'hover:bg-theme-hover cursor-pointer' : 'opacity-50 cursor-not-allowed'
|
|
449
|
+
)}
|
|
450
|
+
>
|
|
451
|
+
<Link2 className="w-3 h-3" />
|
|
452
|
+
upgrade source not tracked
|
|
453
|
+
</button>
|
|
454
|
+
</Tooltip>
|
|
455
|
+
) : (
|
|
456
|
+
// Repo-side error (stale/broken index, classic ambiguity) — not an
|
|
457
|
+
// OCI tracking issue, so surface it without steering to registration.
|
|
458
|
+
<Tooltip content={upgradeInfo.error}>
|
|
459
|
+
<span className="badge bg-theme-hover/50 text-theme-text-secondary">
|
|
460
|
+
upgrade source unresolved
|
|
461
|
+
</span>
|
|
462
|
+
</Tooltip>
|
|
463
|
+
)
|
|
373
464
|
) : null}
|
|
374
465
|
</div>
|
|
375
466
|
<div className="flex items-center gap-1">
|
|
@@ -462,6 +553,11 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
462
553
|
<FetchResult loading={isLoading} error={releaseError} notFoundMessage="Release not found" className="h-32" />
|
|
463
554
|
) : (
|
|
464
555
|
<>
|
|
556
|
+
<HelmOperationBanner
|
|
557
|
+
operation={releaseDetail.lastOperation}
|
|
558
|
+
managedByFluxHelmRelease={releaseDetail.managedByFluxHelmRelease}
|
|
559
|
+
hookDiagnostics={releaseDetail.hookDiagnostics}
|
|
560
|
+
/>
|
|
465
561
|
{activeTab === 'overview' && (
|
|
466
562
|
<OverviewTab release={releaseDetail} onCopy={copyToClipboard} copied={copied} />
|
|
467
563
|
)}
|
|
@@ -469,6 +565,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
469
565
|
<RevisionHistory
|
|
470
566
|
history={releaseDetail.history}
|
|
471
567
|
currentRevision={releaseDetail.revision}
|
|
568
|
+
operations={mergeHelmOperations(releaseDetail.operations, releaseDetail.lastOperation)}
|
|
472
569
|
onViewRevision={handleViewRevision}
|
|
473
570
|
onCompare={handleCompareRevisions}
|
|
474
571
|
onRollback={canHelmWrite ? handleRollbackRequest : undefined}
|
|
@@ -496,6 +593,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
496
593
|
copied={copied === 'values'}
|
|
497
594
|
namespace={helmNamespace}
|
|
498
595
|
name={release.name}
|
|
596
|
+
revision={selectedRevision}
|
|
597
|
+
currentRevision={releaseDetail.revision}
|
|
499
598
|
onApplySuccess={() => refetch()}
|
|
500
599
|
/>
|
|
501
600
|
</RoleGatedPanel>
|
|
@@ -507,17 +606,27 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
507
606
|
/>
|
|
508
607
|
)}
|
|
509
608
|
{activeTab === 'hooks' && (
|
|
510
|
-
<HooksTab hooks={releaseDetail.hooks || []} />
|
|
609
|
+
<HooksTab hooks={releaseDetail.hooks || []} hookDiagnostics={releaseDetail.hookDiagnostics || []} />
|
|
511
610
|
)}
|
|
512
611
|
{activeTab === 'diff' && diffRevisions && (
|
|
513
|
-
<RoleGatedPanel min="member" feature="release
|
|
514
|
-
<
|
|
515
|
-
|
|
516
|
-
isLoading={diffLoading}
|
|
612
|
+
<RoleGatedPanel min="member" feature="release revision comparison">
|
|
613
|
+
<HelmRevisionCompareView
|
|
614
|
+
revisions={releaseDetail.history}
|
|
517
615
|
revision1={diffRevisions.rev1}
|
|
518
616
|
revision2={diffRevisions.rev2}
|
|
617
|
+
mode={compareMode}
|
|
618
|
+
onModeChange={setCompareMode}
|
|
619
|
+
manifestDiff={diffData?.diff || ''}
|
|
620
|
+
manifestLoading={diffLoading}
|
|
621
|
+
valuesDiff={valuesDiffData?.diff || ''}
|
|
622
|
+
valuesLoading={valuesDiffLoading}
|
|
623
|
+
notesDiff={notesDiffData?.diff || ''}
|
|
624
|
+
notesLoading={notesDiffLoading}
|
|
625
|
+
resourceDiff={resourceDiffData}
|
|
626
|
+
resourceLoading={resourceDiffLoading}
|
|
519
627
|
onClose={() => {
|
|
520
628
|
setDiffRevisions(null)
|
|
629
|
+
setCompareMode('summary')
|
|
521
630
|
setActiveTab('history')
|
|
522
631
|
}}
|
|
523
632
|
/>
|
|
@@ -572,6 +681,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
572
681
|
onClose={() => {
|
|
573
682
|
setShowUpgradeConfirm(false)
|
|
574
683
|
setUpgradeProgress([])
|
|
684
|
+
setSelectedVersion(null)
|
|
575
685
|
if (isUpgrading) {
|
|
576
686
|
// Upgrade continues server-side — switch to resources tab to monitor
|
|
577
687
|
setIsUpgrading(false)
|
|
@@ -580,18 +690,56 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
580
690
|
}}
|
|
581
691
|
onConfirm={handleUpgradeConfirm}
|
|
582
692
|
title="Upgrade Release"
|
|
583
|
-
message={`Upgrade "${release.name}" to version ${
|
|
693
|
+
message={`Upgrade "${release.name}" to version ${targetVersion}?`}
|
|
584
694
|
details={upgradeProgress.length === 0
|
|
585
|
-
? `
|
|
695
|
+
? `The chart will move from version ${upgradeInfo?.currentVersion} to ${targetVersion}. Your existing values will be preserved. The change is applied to your cluster immediately.`
|
|
586
696
|
: undefined
|
|
587
697
|
}
|
|
588
|
-
confirmLabel=
|
|
698
|
+
confirmLabel={isDowngrade ? 'Downgrade' : 'Upgrade'}
|
|
589
699
|
variant="warning"
|
|
590
700
|
isLoading={isUpgrading}
|
|
591
701
|
isClosable
|
|
592
702
|
>
|
|
703
|
+
{upgradeProgress.length === 0 && availableVersions && availableVersions.length > 1 && (
|
|
704
|
+
<div className="mb-1">
|
|
705
|
+
<label htmlFor="upgrade-version" className="block text-sm font-medium text-theme-text-secondary mb-1.5">
|
|
706
|
+
Target version
|
|
707
|
+
</label>
|
|
708
|
+
<select
|
|
709
|
+
id="upgrade-version"
|
|
710
|
+
value={targetVersion}
|
|
711
|
+
onChange={(e) => setSelectedVersion(e.target.value)}
|
|
712
|
+
disabled={isUpgrading}
|
|
713
|
+
className="w-full px-3 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
|
|
714
|
+
>
|
|
715
|
+
{availableVersions.map((v) => (
|
|
716
|
+
<option key={v} value={v}>
|
|
717
|
+
{v}
|
|
718
|
+
{v === upgradeInfo?.latestVersion ? ' (latest)' : ''}
|
|
719
|
+
{v === upgradeInfo?.currentVersion ? ' (current)' : ''}
|
|
720
|
+
</option>
|
|
721
|
+
))}
|
|
722
|
+
</select>
|
|
723
|
+
{isDowngrade && (
|
|
724
|
+
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
|
725
|
+
This is a downgrade from {upgradeInfo?.currentVersion}.
|
|
726
|
+
</p>
|
|
727
|
+
)}
|
|
728
|
+
{availableVersions.length >= 50 && (
|
|
729
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
730
|
+
Showing the 50 newest versions. Type to filter.
|
|
731
|
+
</p>
|
|
732
|
+
)}
|
|
733
|
+
</div>
|
|
734
|
+
)}
|
|
593
735
|
{upgradeProgress.length > 0 && <ProgressLog entries={upgradeProgress} />}
|
|
594
736
|
</ConfirmDialog>
|
|
737
|
+
|
|
738
|
+
<TrackChartSourceDialog
|
|
739
|
+
open={showTrackSource}
|
|
740
|
+
onClose={() => setShowTrackSource(false)}
|
|
741
|
+
chartName={releaseDetail?.chart}
|
|
742
|
+
/>
|
|
595
743
|
</div>
|
|
596
744
|
)
|
|
597
745
|
}
|
|
@@ -623,6 +771,443 @@ function ProgressLog({ entries }: { entries: { phase: string; message: string }[
|
|
|
623
771
|
)
|
|
624
772
|
}
|
|
625
773
|
|
|
774
|
+
function mergeHelmOperations(operations: HelmOperation[] | undefined, lastOperation: HelmOperation | undefined): HelmOperation[] {
|
|
775
|
+
const merged: HelmOperation[] = []
|
|
776
|
+
const seen = new Set<string>()
|
|
777
|
+
for (const op of [...(operations || []), ...(lastOperation ? [lastOperation] : [])]) {
|
|
778
|
+
const key = helmOperationKey(op)
|
|
779
|
+
if (seen.has(key)) continue
|
|
780
|
+
seen.add(key)
|
|
781
|
+
merged.push(op)
|
|
782
|
+
}
|
|
783
|
+
return merged
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
interface HelmRevisionCompareViewProps {
|
|
787
|
+
revisions: HelmRevision[]
|
|
788
|
+
revision1: number
|
|
789
|
+
revision2: number
|
|
790
|
+
mode: CompareMode
|
|
791
|
+
onModeChange: (mode: CompareMode) => void
|
|
792
|
+
manifestDiff: string
|
|
793
|
+
manifestLoading: boolean
|
|
794
|
+
valuesDiff: string
|
|
795
|
+
valuesLoading: boolean
|
|
796
|
+
notesDiff: string
|
|
797
|
+
notesLoading: boolean
|
|
798
|
+
resourceDiff?: ResourceDiff
|
|
799
|
+
resourceLoading: boolean
|
|
800
|
+
onClose: () => void
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function HelmRevisionCompareView({
|
|
804
|
+
revisions,
|
|
805
|
+
revision1,
|
|
806
|
+
revision2,
|
|
807
|
+
mode,
|
|
808
|
+
onModeChange,
|
|
809
|
+
manifestDiff,
|
|
810
|
+
manifestLoading,
|
|
811
|
+
valuesDiff,
|
|
812
|
+
valuesLoading,
|
|
813
|
+
notesDiff,
|
|
814
|
+
notesLoading,
|
|
815
|
+
resourceDiff,
|
|
816
|
+
resourceLoading,
|
|
817
|
+
onClose,
|
|
818
|
+
}: HelmRevisionCompareViewProps) {
|
|
819
|
+
const left = revisions.find((r) => r.revision === revision1)
|
|
820
|
+
const right = revisions.find((r) => r.revision === revision2)
|
|
821
|
+
const modes: Array<{ id: CompareMode; label: string; icon: typeof GitCompare }> = [
|
|
822
|
+
{ id: 'summary', label: 'Summary', icon: GitCompare },
|
|
823
|
+
{ id: 'values', label: 'Values', icon: Settings },
|
|
824
|
+
{ id: 'manifest', label: 'Manifest', icon: Code },
|
|
825
|
+
{ id: 'notes', label: 'Notes', icon: FileText },
|
|
826
|
+
{ id: 'resources', label: 'Resources', icon: Link2 },
|
|
827
|
+
]
|
|
828
|
+
|
|
829
|
+
return (
|
|
830
|
+
<div className="p-4">
|
|
831
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
832
|
+
<div className="min-w-0">
|
|
833
|
+
<div className="flex items-center gap-2">
|
|
834
|
+
<GitCompare className="h-4 w-4 text-theme-text-secondary" />
|
|
835
|
+
<span className="text-sm font-medium text-theme-text-primary">Revision {revision1} -> {revision2}</span>
|
|
836
|
+
</div>
|
|
837
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
838
|
+
Compare rendered output and release metadata between two Helm revisions.
|
|
839
|
+
</p>
|
|
840
|
+
</div>
|
|
841
|
+
<button
|
|
842
|
+
onClick={onClose}
|
|
843
|
+
className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
844
|
+
>
|
|
845
|
+
<X className="w-3.5 h-3.5" />
|
|
846
|
+
Close
|
|
847
|
+
</button>
|
|
848
|
+
</div>
|
|
849
|
+
|
|
850
|
+
<div className="mb-4 flex flex-wrap gap-1 rounded-lg bg-theme-base/50 p-1">
|
|
851
|
+
{modes.map((item) => (
|
|
852
|
+
<button
|
|
853
|
+
key={item.id}
|
|
854
|
+
onClick={() => onModeChange(item.id)}
|
|
855
|
+
className={clsx(
|
|
856
|
+
'flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors',
|
|
857
|
+
mode === item.id
|
|
858
|
+
? 'bg-theme-elevated text-theme-text-primary shadow-theme-sm'
|
|
859
|
+
: 'text-theme-text-secondary hover:bg-theme-hover/70 hover:text-theme-text-primary'
|
|
860
|
+
)}
|
|
861
|
+
>
|
|
862
|
+
<item.icon className="h-3.5 w-3.5" />
|
|
863
|
+
{item.label}
|
|
864
|
+
</button>
|
|
865
|
+
))}
|
|
866
|
+
</div>
|
|
867
|
+
|
|
868
|
+
{mode === 'summary' && <RevisionCompareSummary left={left} right={right} revision1={revision1} revision2={revision2} />}
|
|
869
|
+
{mode === 'values' && (
|
|
870
|
+
<ManifestDiffViewer
|
|
871
|
+
diff={valuesDiff}
|
|
872
|
+
isLoading={valuesLoading}
|
|
873
|
+
revision1={revision1}
|
|
874
|
+
revision2={revision2}
|
|
875
|
+
title={`Values diff: Revision ${revision1} -> ${revision2}`}
|
|
876
|
+
emptyLabel="No user-supplied value changes found"
|
|
877
|
+
onClose={onClose}
|
|
878
|
+
/>
|
|
879
|
+
)}
|
|
880
|
+
{mode === 'manifest' && (
|
|
881
|
+
<ManifestDiffViewer
|
|
882
|
+
diff={manifestDiff}
|
|
883
|
+
isLoading={manifestLoading}
|
|
884
|
+
revision1={revision1}
|
|
885
|
+
revision2={revision2}
|
|
886
|
+
title={`Manifest diff: Revision ${revision1} -> ${revision2}`}
|
|
887
|
+
onClose={onClose}
|
|
888
|
+
/>
|
|
889
|
+
)}
|
|
890
|
+
{mode === 'notes' && (
|
|
891
|
+
<ManifestDiffViewer
|
|
892
|
+
diff={notesDiff}
|
|
893
|
+
isLoading={notesLoading}
|
|
894
|
+
revision1={revision1}
|
|
895
|
+
revision2={revision2}
|
|
896
|
+
title={`Notes diff: Revision ${revision1} -> ${revision2}`}
|
|
897
|
+
emptyLabel="No release notes changes found"
|
|
898
|
+
onClose={onClose}
|
|
899
|
+
/>
|
|
900
|
+
)}
|
|
901
|
+
{mode === 'resources' && (
|
|
902
|
+
<ResourceDiffView diff={resourceDiff} isLoading={resourceLoading} revision1={revision1} revision2={revision2} />
|
|
903
|
+
)}
|
|
904
|
+
</div>
|
|
905
|
+
)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function RevisionCompareSummary({
|
|
909
|
+
left,
|
|
910
|
+
right,
|
|
911
|
+
revision1,
|
|
912
|
+
revision2,
|
|
913
|
+
}: {
|
|
914
|
+
left?: HelmRevision
|
|
915
|
+
right?: HelmRevision
|
|
916
|
+
revision1: number
|
|
917
|
+
revision2: number
|
|
918
|
+
}) {
|
|
919
|
+
return (
|
|
920
|
+
<div className="space-y-4">
|
|
921
|
+
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
922
|
+
<RevisionSummaryCard revision={left} fallbackRevision={revision1} label="From" />
|
|
923
|
+
<RevisionSummaryCard revision={right} fallbackRevision={revision2} label="To" />
|
|
924
|
+
</div>
|
|
925
|
+
<div className="card-inner-lg">
|
|
926
|
+
<h3 className="text-sm font-medium text-theme-text-secondary">Changed fields</h3>
|
|
927
|
+
<div className="mt-3 grid grid-cols-1 gap-2 text-sm md:grid-cols-2">
|
|
928
|
+
<RevisionFieldDelta label="Chart" left={left?.chart} right={right?.chart} />
|
|
929
|
+
<RevisionFieldDelta label="App version" left={left?.appVersion || '-'} right={right?.appVersion || '-'} />
|
|
930
|
+
<RevisionFieldDelta label="Status" left={left?.status} right={right?.status} />
|
|
931
|
+
<RevisionFieldDelta label="Description" left={left?.description || '-'} right={right?.description || '-'} />
|
|
932
|
+
</div>
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function RevisionSummaryCard({ revision, fallbackRevision, label }: { revision?: HelmRevision; fallbackRevision: number; label: string }) {
|
|
939
|
+
return (
|
|
940
|
+
<div className="card-inner-lg">
|
|
941
|
+
<div className="flex items-center justify-between gap-2">
|
|
942
|
+
<span className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</span>
|
|
943
|
+
<span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">rev {revision?.revision || fallbackRevision}</span>
|
|
944
|
+
</div>
|
|
945
|
+
<div className="mt-3 space-y-2 text-sm">
|
|
946
|
+
<div className="flex items-center justify-between gap-3">
|
|
947
|
+
<span className="text-theme-text-tertiary">Chart</span>
|
|
948
|
+
<span className="truncate text-theme-text-primary">{revision?.chart || '-'}</span>
|
|
949
|
+
</div>
|
|
950
|
+
<div className="flex items-center justify-between gap-3">
|
|
951
|
+
<span className="text-theme-text-tertiary">Status</span>
|
|
952
|
+
<span className={clsx('badge-sm', revision ? getHelmStatusColor(revision.status) : SEVERITY_BADGE.neutral)}>
|
|
953
|
+
{revision?.status || 'unknown'}
|
|
954
|
+
</span>
|
|
955
|
+
</div>
|
|
956
|
+
<div className="flex items-center justify-between gap-3">
|
|
957
|
+
<span className="text-theme-text-tertiary">Updated</span>
|
|
958
|
+
<span className="text-theme-text-secondary">{revision?.updated ? formatDate(revision.updated) : '-'}</span>
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
</div>
|
|
962
|
+
)
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function RevisionFieldDelta({ label, left, right }: { label: string; left?: string; right?: string }) {
|
|
966
|
+
const changed = (left || '') !== (right || '')
|
|
967
|
+
return (
|
|
968
|
+
<div className="rounded-md bg-theme-base/40 p-2">
|
|
969
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
970
|
+
<span className="text-xs text-theme-text-tertiary">{label}</span>
|
|
971
|
+
<span className={clsx('badge-sm', changed ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
|
|
972
|
+
{changed ? 'changed' : 'same'}
|
|
973
|
+
</span>
|
|
974
|
+
</div>
|
|
975
|
+
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-xs">
|
|
976
|
+
<span className="truncate text-theme-text-secondary">{left || '-'}</span>
|
|
977
|
+
<span className="text-theme-text-tertiary">-></span>
|
|
978
|
+
<span className="truncate text-theme-text-primary">{right || '-'}</span>
|
|
979
|
+
</div>
|
|
980
|
+
</div>
|
|
981
|
+
)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function ResourceDiffView({
|
|
985
|
+
diff,
|
|
986
|
+
isLoading,
|
|
987
|
+
revision1,
|
|
988
|
+
revision2,
|
|
989
|
+
}: {
|
|
990
|
+
diff?: ResourceDiff
|
|
991
|
+
isLoading: boolean
|
|
992
|
+
revision1: number
|
|
993
|
+
revision2: number
|
|
994
|
+
}) {
|
|
995
|
+
if (isLoading) {
|
|
996
|
+
return <FetchResult loading className="h-32" />
|
|
997
|
+
}
|
|
998
|
+
if (!diff) {
|
|
999
|
+
return <FetchResult loading={false} notFoundMessage="Resource diff not available" className="h-32" />
|
|
1000
|
+
}
|
|
1001
|
+
return (
|
|
1002
|
+
<div className="space-y-3">
|
|
1003
|
+
<div className="text-sm font-medium text-theme-text-secondary">
|
|
1004
|
+
Resource set diff: Revision {revision1} -> {revision2}
|
|
1005
|
+
</div>
|
|
1006
|
+
<ResourceDiffGroup title="Added" icon={Plus} tone="success" resources={diff.added} />
|
|
1007
|
+
<ResourceDiffGroup title="Removed" icon={Minus} tone="error" resources={diff.removed} />
|
|
1008
|
+
<ResourceDiffGroup title="Unchanged" icon={Equal} tone="neutral" resources={diff.unchanged} collapsed />
|
|
1009
|
+
</div>
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function ResourceDiffGroup({
|
|
1014
|
+
title,
|
|
1015
|
+
icon: Icon,
|
|
1016
|
+
tone,
|
|
1017
|
+
resources,
|
|
1018
|
+
collapsed = false,
|
|
1019
|
+
}: {
|
|
1020
|
+
title: string
|
|
1021
|
+
icon: typeof Plus
|
|
1022
|
+
tone: keyof typeof SEVERITY_BADGE
|
|
1023
|
+
resources: ResourceDiff['added']
|
|
1024
|
+
collapsed?: boolean
|
|
1025
|
+
}) {
|
|
1026
|
+
const items = resources || []
|
|
1027
|
+
const visible = collapsed ? items.slice(0, 12) : items
|
|
1028
|
+
return (
|
|
1029
|
+
<div className="card-inner-lg">
|
|
1030
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
1031
|
+
<div className="flex items-center gap-2">
|
|
1032
|
+
<Icon className="h-4 w-4 text-theme-text-secondary" />
|
|
1033
|
+
<span className="text-sm font-medium text-theme-text-primary">{title}</span>
|
|
1034
|
+
</div>
|
|
1035
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{items.length}</span>
|
|
1036
|
+
</div>
|
|
1037
|
+
{items.length === 0 ? (
|
|
1038
|
+
<div className="text-sm text-theme-text-tertiary">None</div>
|
|
1039
|
+
) : (
|
|
1040
|
+
<div className="space-y-1">
|
|
1041
|
+
{visible.map((resource) => (
|
|
1042
|
+
<div key={`${resource.apiVersion}/${resource.kind}/${resource.namespace}/${resource.name}`} className="grid grid-cols-[110px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1 text-xs">
|
|
1043
|
+
<span className="text-theme-text-tertiary">{resource.kind}</span>
|
|
1044
|
+
<span className="truncate text-theme-text-primary">
|
|
1045
|
+
{resource.namespace ? `${resource.namespace}/` : ''}{resource.name}
|
|
1046
|
+
</span>
|
|
1047
|
+
</div>
|
|
1048
|
+
))}
|
|
1049
|
+
{visible.length < items.length && (
|
|
1050
|
+
<div className="text-xs text-theme-text-tertiary">+{items.length - visible.length} more unchanged resources</div>
|
|
1051
|
+
)}
|
|
1052
|
+
</div>
|
|
1053
|
+
)}
|
|
1054
|
+
</div>
|
|
1055
|
+
)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function helmOperationKey(operation: HelmOperation): string {
|
|
1059
|
+
return [
|
|
1060
|
+
operation.kind,
|
|
1061
|
+
operation.status,
|
|
1062
|
+
operation.revision || 0,
|
|
1063
|
+
operation.failedRevision || 0,
|
|
1064
|
+
operation.rollbackRevision || 0,
|
|
1065
|
+
operation.targetRevision || 0,
|
|
1066
|
+
].join(':')
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function HelmOperationBanner({
|
|
1070
|
+
operation,
|
|
1071
|
+
managedByFluxHelmRelease,
|
|
1072
|
+
hookDiagnostics,
|
|
1073
|
+
}: {
|
|
1074
|
+
operation?: HelmOperation
|
|
1075
|
+
managedByFluxHelmRelease?: string
|
|
1076
|
+
hookDiagnostics?: HookDiagnostic[]
|
|
1077
|
+
}) {
|
|
1078
|
+
const primaryHookDiagnostic = hookDiagnostics?.[0]
|
|
1079
|
+
if ((!operation || !shouldShowOperationBanner(operation)) && !primaryHookDiagnostic) {
|
|
1080
|
+
return null
|
|
1081
|
+
}
|
|
1082
|
+
if (!operation || !shouldShowOperationBanner(operation)) {
|
|
1083
|
+
const tone = hookDiagnosticTone(primaryHookDiagnostic!)
|
|
1084
|
+
return (
|
|
1085
|
+
<div className="m-4 mb-0 card-inner-lg">
|
|
1086
|
+
<div className="flex items-start gap-3">
|
|
1087
|
+
<AlertTriangle className={clsx('mt-0.5 h-5 w-5 shrink-0', SEVERITY_TEXT[tone])} />
|
|
1088
|
+
<div className="min-w-0 flex-1">
|
|
1089
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1090
|
+
<span className="text-sm font-medium text-theme-text-primary">Helm hook needs attention</span>
|
|
1091
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{primaryHookDiagnostic!.phase}</span>
|
|
1092
|
+
</div>
|
|
1093
|
+
<HookSignal diagnostic={primaryHookDiagnostic!} />
|
|
1094
|
+
</div>
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
1097
|
+
)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const isFailure = operation.status === 'failed'
|
|
1101
|
+
const isPending = operation.status === 'stuck_pending'
|
|
1102
|
+
const tone: 'error' | 'warning' | 'info' = isFailure ? 'error' : operation.kind === 'rollback' ? 'info' : 'warning'
|
|
1103
|
+
const Icon = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' ? RotateCcw : isPending ? Clock : AlertTriangle
|
|
1104
|
+
const title = operationTitle(operation)
|
|
1105
|
+
const statusLabel = operation.status.replace(/_/g, ' ')
|
|
1106
|
+
const showStatusBadge = !(operation.status === 'failed' && title.toLowerCase().includes('failed'))
|
|
1107
|
+
|
|
1108
|
+
return (
|
|
1109
|
+
<div className="m-4 mb-0 card-inner-lg">
|
|
1110
|
+
<div className="flex items-start gap-3">
|
|
1111
|
+
<Icon className={clsx('mt-0.5 h-5 w-5 shrink-0', SEVERITY_TEXT[tone])} />
|
|
1112
|
+
<div className="min-w-0 flex-1">
|
|
1113
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1114
|
+
<span className="text-sm font-medium text-theme-text-primary">{title}</span>
|
|
1115
|
+
{showStatusBadge && (
|
|
1116
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{statusLabel}</span>
|
|
1117
|
+
)}
|
|
1118
|
+
<OperationRevisionChips operation={operation} />
|
|
1119
|
+
</div>
|
|
1120
|
+
<p className="mt-1 text-sm text-theme-text-secondary">{operation.message}</p>
|
|
1121
|
+
{operation.failureDescription && (
|
|
1122
|
+
<p className="mt-1 text-xs text-theme-text-tertiary truncate">
|
|
1123
|
+
{operation.failureDescription}
|
|
1124
|
+
</p>
|
|
1125
|
+
)}
|
|
1126
|
+
{operation.kind === 'upgrade_rolled_back' && (
|
|
1127
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
1128
|
+
Helm history does not record whether <code className="inline-code text-[11px]">--atomic</code> was set; the rollback is inferred from adjacent release revisions.
|
|
1129
|
+
</p>
|
|
1130
|
+
)}
|
|
1131
|
+
{primaryHookDiagnostic && <HookSignal diagnostic={primaryHookDiagnostic} />}
|
|
1132
|
+
{managedByFluxHelmRelease && (
|
|
1133
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
1134
|
+
This release is managed by Flux HelmRelease {managedByFluxHelmRelease}; direct Helm changes may be reconciled back.
|
|
1135
|
+
</p>
|
|
1136
|
+
)}
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function hookDiagnosticTone(diagnostic: HookDiagnostic): 'error' | 'warning' {
|
|
1144
|
+
return diagnostic.phase.toLowerCase() === 'failed' ? 'error' : 'warning'
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function HookSignal({ diagnostic }: { diagnostic: HookDiagnostic }) {
|
|
1148
|
+
return (
|
|
1149
|
+
<div className="mt-2 rounded-md bg-theme-base/50 p-2 text-xs">
|
|
1150
|
+
<div className="font-medium text-theme-text-secondary">
|
|
1151
|
+
Hook signal: {formatHookRef(diagnostic)} is {diagnostic.phase}
|
|
1152
|
+
</div>
|
|
1153
|
+
<div className="mt-1 text-theme-text-tertiary">{diagnostic.message}</div>
|
|
1154
|
+
{diagnostic.evidence?.summary && (
|
|
1155
|
+
<div className="mt-1 text-theme-text-secondary">{diagnostic.evidence.summary}</div>
|
|
1156
|
+
)}
|
|
1157
|
+
{diagnostic.evidenceUnavailableReason && (
|
|
1158
|
+
<div className="mt-1 text-theme-text-tertiary">{diagnostic.evidenceUnavailableReason}</div>
|
|
1159
|
+
)}
|
|
1160
|
+
</div>
|
|
1161
|
+
)
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function shouldShowOperationBanner(operation: HelmOperation): boolean {
|
|
1165
|
+
return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'failed' || operation.status === 'stuck_pending'
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function operationTitle(operation: HelmOperation): string {
|
|
1169
|
+
switch (operation.kind) {
|
|
1170
|
+
case 'upgrade_rolled_back':
|
|
1171
|
+
return 'Helm rolled back after failed upgrade'
|
|
1172
|
+
case 'rollback':
|
|
1173
|
+
return 'Helm rollback applied'
|
|
1174
|
+
case 'pending':
|
|
1175
|
+
return 'Helm operation may be stuck'
|
|
1176
|
+
case 'upgrade_failed':
|
|
1177
|
+
return 'Helm upgrade failed'
|
|
1178
|
+
case 'release_failed':
|
|
1179
|
+
return 'Helm release failed'
|
|
1180
|
+
default:
|
|
1181
|
+
return 'Helm operation'
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function OperationRevisionChips({ operation }: { operation: HelmOperation }) {
|
|
1186
|
+
const chips: Array<{ key: string; label: string; className: string }> = []
|
|
1187
|
+
if (operation.failedRevision) {
|
|
1188
|
+
chips.push({ key: 'failed', label: `failed rev ${operation.failedRevision}`, className: SEVERITY_BADGE.error })
|
|
1189
|
+
}
|
|
1190
|
+
if (operation.rollbackRevision) {
|
|
1191
|
+
chips.push({ key: 'rollback', label: `rollback rev ${operation.rollbackRevision}`, className: SEVERITY_BADGE.warning })
|
|
1192
|
+
}
|
|
1193
|
+
if (operation.targetRevision) {
|
|
1194
|
+
chips.push({ key: 'target', label: `target rev ${operation.targetRevision}`, className: SEVERITY_BADGE.info })
|
|
1195
|
+
}
|
|
1196
|
+
if (!operation.failedRevision && !operation.rollbackRevision && operation.revision) {
|
|
1197
|
+
chips.push({ key: 'revision', label: `rev ${operation.revision}`, className: SEVERITY_BADGE.neutral })
|
|
1198
|
+
}
|
|
1199
|
+
if (chips.length === 0) {
|
|
1200
|
+
return null
|
|
1201
|
+
}
|
|
1202
|
+
return (
|
|
1203
|
+
<>
|
|
1204
|
+
{chips.map((chip) => (
|
|
1205
|
+
<span key={chip.key} className={clsx('badge-sm', chip.className)}>{chip.label}</span>
|
|
1206
|
+
))}
|
|
1207
|
+
</>
|
|
1208
|
+
)
|
|
1209
|
+
}
|
|
1210
|
+
|
|
626
1211
|
// Overview tab content
|
|
627
1212
|
interface OverviewTabProps {
|
|
628
1213
|
release: {
|
|
@@ -758,9 +1343,10 @@ function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
|
|
|
758
1343
|
// Hooks tab content
|
|
759
1344
|
interface HooksTabProps {
|
|
760
1345
|
hooks: HelmHook[]
|
|
1346
|
+
hookDiagnostics: HookDiagnostic[]
|
|
761
1347
|
}
|
|
762
1348
|
|
|
763
|
-
function HooksTab({ hooks }: HooksTabProps) {
|
|
1349
|
+
function HooksTab({ hooks, hookDiagnostics }: HooksTabProps) {
|
|
764
1350
|
if (hooks.length === 0) {
|
|
765
1351
|
return (
|
|
766
1352
|
<div className="flex flex-col items-center justify-center h-48 text-theme-text-tertiary">
|
|
@@ -792,23 +1378,31 @@ function HooksTab({ hooks }: HooksTabProps) {
|
|
|
792
1378
|
return SEVERITY_BADGE.neutral
|
|
793
1379
|
}
|
|
794
1380
|
|
|
1381
|
+
const diagnostics = new Map<string, HookDiagnostic>()
|
|
1382
|
+
for (const diagnostic of hookDiagnostics) {
|
|
1383
|
+
diagnostics.set(hookDiagnosticKey(diagnostic.namespace, diagnostic.kind, diagnostic.name), diagnostic)
|
|
1384
|
+
diagnostics.set(hookDiagnosticKey(undefined, diagnostic.kind, diagnostic.name), diagnostic)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
795
1387
|
return (
|
|
796
1388
|
<div className="p-4 space-y-3">
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
<div key={hook.name} className="bg-theme-elevated/30 rounded-lg p-4">
|
|
802
|
-
<div className="flex items-start justify-between mb-2">
|
|
803
|
-
<div>
|
|
804
|
-
<div className="flex items-center gap-2">
|
|
805
|
-
<span className="text-theme-text-primary font-medium">{hook.name}</span>
|
|
806
|
-
<span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">
|
|
1389
|
+
{hooks.map((hook) => {
|
|
1390
|
+
const diagnostic = diagnostics.get(hookDiagnosticKey(hook.namespace, hook.kind, hook.name))
|
|
1391
|
+
|| diagnostics.get(hookDiagnosticKey(undefined, hook.kind, hook.name))
|
|
1392
|
+
return (
|
|
1393
|
+
<div key={`${hook.namespace || '_'}:${hook.kind}:${hook.name}`} className="bg-theme-elevated/30 rounded-lg p-4">
|
|
1394
|
+
<div className="flex items-start justify-between gap-3 mb-2">
|
|
1395
|
+
<div className="min-w-0">
|
|
1396
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1397
|
+
<span className="break-all text-theme-text-primary font-medium">{hook.name}</span>
|
|
1398
|
+
<span className="badge-sm shrink-0 bg-theme-hover/50 text-theme-text-secondary">
|
|
807
1399
|
{hook.kind}
|
|
808
1400
|
</span>
|
|
809
1401
|
</div>
|
|
810
|
-
<div className="flex
|
|
1402
|
+
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
|
|
1403
|
+
{hook.namespace && <span className="break-all">Namespace: {hook.namespace}</span>}
|
|
811
1404
|
<span>Weight: {hook.weight}</span>
|
|
1405
|
+
{hook.path && <span className="break-all">Path: {hook.path}</span>}
|
|
812
1406
|
</div>
|
|
813
1407
|
</div>
|
|
814
1408
|
{hook.status && (
|
|
@@ -827,8 +1421,172 @@ function HooksTab({ hooks }: HooksTabProps) {
|
|
|
827
1421
|
</span>
|
|
828
1422
|
))}
|
|
829
1423
|
</div>
|
|
1424
|
+
{(hook.startedAt || hook.completedAt || hook.deletePolicies?.length || hook.outputLogPolicies?.length) && (
|
|
1425
|
+
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-theme-text-tertiary sm:grid-cols-2">
|
|
1426
|
+
{hook.startedAt && (
|
|
1427
|
+
<div>
|
|
1428
|
+
<span className="text-theme-text-disabled">Started: </span>
|
|
1429
|
+
<span>{formatDate(hook.startedAt)}</span>
|
|
1430
|
+
</div>
|
|
1431
|
+
)}
|
|
1432
|
+
{hook.completedAt && (
|
|
1433
|
+
<div>
|
|
1434
|
+
<span className="text-theme-text-disabled">Completed: </span>
|
|
1435
|
+
<span>{formatDate(hook.completedAt)}</span>
|
|
1436
|
+
</div>
|
|
1437
|
+
)}
|
|
1438
|
+
{hook.deletePolicies && hook.deletePolicies.length > 0 && (
|
|
1439
|
+
<div className="sm:col-span-2">
|
|
1440
|
+
<span className="text-theme-text-disabled">Delete policies: </span>
|
|
1441
|
+
<span>{hook.deletePolicies.join(', ')}</span>
|
|
1442
|
+
</div>
|
|
1443
|
+
)}
|
|
1444
|
+
{hook.outputLogPolicies && hook.outputLogPolicies.length > 0 && (
|
|
1445
|
+
<div className="sm:col-span-2">
|
|
1446
|
+
<span className="text-theme-text-disabled">Output log policies: </span>
|
|
1447
|
+
<span>{hook.outputLogPolicies.join(', ')}</span>
|
|
1448
|
+
</div>
|
|
1449
|
+
)}
|
|
1450
|
+
</div>
|
|
1451
|
+
)}
|
|
1452
|
+
{diagnostic && <HookDiagnosticEvidence diagnostic={diagnostic} />}
|
|
830
1453
|
</div>
|
|
831
|
-
|
|
1454
|
+
)
|
|
1455
|
+
})}
|
|
1456
|
+
</div>
|
|
1457
|
+
)
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function hookDiagnosticKey(namespace: string | undefined, kind: string | undefined, name: string | undefined): string {
|
|
1461
|
+
return `${namespace || ''}/${(kind || '').toLowerCase()}/${name || ''}`
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function formatHookRef(hook: Pick<HookDiagnostic, 'namespace' | 'kind' | 'name'>): string {
|
|
1465
|
+
return `${hook.namespace ? `${hook.namespace}/` : ''}${hook.name} (${hook.kind})`
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function HookDiagnosticEvidence({ diagnostic }: { diagnostic: HookDiagnostic }) {
|
|
1469
|
+
const evidence = diagnostic.evidence
|
|
1470
|
+
|
|
1471
|
+
return (
|
|
1472
|
+
<div className="mt-3 border-t border-theme-border/60 pt-3 text-xs">
|
|
1473
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1474
|
+
<span className="font-medium text-theme-text-secondary">Diagnostic evidence</span>
|
|
1475
|
+
<span className={clsx('badge-sm', diagnostic.phase.toLowerCase() === 'failed' ? SEVERITY_BADGE.error : SEVERITY_BADGE.warning)}>
|
|
1476
|
+
{diagnostic.phase}
|
|
1477
|
+
</span>
|
|
1478
|
+
{evidence?.summary && <span className="text-theme-text-tertiary">{evidence.summary}</span>}
|
|
1479
|
+
</div>
|
|
1480
|
+
<div className="mt-1 text-theme-text-tertiary">{diagnostic.message}</div>
|
|
1481
|
+
{diagnostic.evidenceUnavailableReason && (
|
|
1482
|
+
<div className="mt-1 text-theme-text-tertiary">{diagnostic.evidenceUnavailableReason}</div>
|
|
1483
|
+
)}
|
|
1484
|
+
{evidence && (
|
|
1485
|
+
<div className="mt-3 space-y-3">
|
|
1486
|
+
{evidence.jobs && evidence.jobs.length > 0 && (
|
|
1487
|
+
<div className="space-y-1">
|
|
1488
|
+
{evidence.jobs.map((job) => (
|
|
1489
|
+
<div key={`${job.namespace || ''}/${job.name}`} className="grid grid-cols-[80px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1">
|
|
1490
|
+
<span className="text-theme-text-tertiary">Job</span>
|
|
1491
|
+
<span className="min-w-0 text-theme-text-primary">
|
|
1492
|
+
<span className="break-all font-medium">{job.namespace ? `${job.namespace}/` : ''}{job.name}</span>
|
|
1493
|
+
{job.status && <span className="ml-2 text-theme-text-secondary">{job.status}</span>}
|
|
1494
|
+
<span className="ml-2 text-theme-text-tertiary">
|
|
1495
|
+
active {job.active || 0} · succeeded {job.succeeded || 0} · failed {job.failed || 0}
|
|
1496
|
+
</span>
|
|
1497
|
+
</span>
|
|
1498
|
+
{job.conditions?.map((condition) => (
|
|
1499
|
+
<span key={condition} className="col-start-2 text-theme-text-tertiary">{condition}</span>
|
|
1500
|
+
))}
|
|
1501
|
+
</div>
|
|
1502
|
+
))}
|
|
1503
|
+
</div>
|
|
1504
|
+
)}
|
|
1505
|
+
|
|
1506
|
+
{evidence.pods && evidence.pods.length > 0 && (
|
|
1507
|
+
<div className="space-y-1">
|
|
1508
|
+
{evidence.pods.map((pod) => (
|
|
1509
|
+
<div key={`${pod.namespace || ''}/${pod.name}`} className="grid grid-cols-[80px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1">
|
|
1510
|
+
<span className="text-theme-text-tertiary">Pod</span>
|
|
1511
|
+
<span className="min-w-0 text-theme-text-primary">
|
|
1512
|
+
<span className="break-all font-medium">{pod.namespace ? `${pod.namespace}/` : ''}{pod.name}</span>
|
|
1513
|
+
{pod.phase && <span className="ml-2 text-theme-text-secondary">{pod.phase}</span>}
|
|
1514
|
+
{pod.ready && <span className="ml-2 text-theme-text-tertiary">{pod.ready} ready</span>}
|
|
1515
|
+
{Boolean(pod.restartCount) && <span className="ml-2 text-theme-text-tertiary">{pod.restartCount} restarts</span>}
|
|
1516
|
+
</span>
|
|
1517
|
+
{(pod.reason || pod.message) && (
|
|
1518
|
+
<span className="col-start-2 text-theme-text-tertiary">
|
|
1519
|
+
{pod.reason && <span className="font-medium">{pod.reason}: </span>}
|
|
1520
|
+
{pod.message}
|
|
1521
|
+
</span>
|
|
1522
|
+
)}
|
|
1523
|
+
</div>
|
|
1524
|
+
))}
|
|
1525
|
+
</div>
|
|
1526
|
+
)}
|
|
1527
|
+
|
|
1528
|
+
{evidence.events && evidence.events.length > 0 && (
|
|
1529
|
+
<div className="space-y-1">
|
|
1530
|
+
{evidence.events.map((event, index) => (
|
|
1531
|
+
<div key={`${event.involvedKind}/${event.involvedName}/${event.reason || index}`} className="grid grid-cols-[80px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1">
|
|
1532
|
+
<span className="text-theme-text-tertiary">Event</span>
|
|
1533
|
+
<span className="min-w-0 text-theme-text-primary">
|
|
1534
|
+
<span className={clsx('badge-sm mr-2', event.type === 'Warning' ? SEVERITY_BADGE.warning : SEVERITY_BADGE.neutral)}>
|
|
1535
|
+
{event.type || 'Event'}
|
|
1536
|
+
</span>
|
|
1537
|
+
<span className="font-medium">{event.reason || 'Unknown'}</span>
|
|
1538
|
+
<span className="ml-2 break-all text-theme-text-tertiary">{event.involvedKind}/{event.involvedName}</span>
|
|
1539
|
+
</span>
|
|
1540
|
+
{event.message && <span className="col-start-2 text-theme-text-tertiary">{event.message}</span>}
|
|
1541
|
+
</div>
|
|
1542
|
+
))}
|
|
1543
|
+
</div>
|
|
1544
|
+
)}
|
|
1545
|
+
|
|
1546
|
+
{evidence.logs && evidence.logs.length > 0 && (
|
|
1547
|
+
<div className="space-y-2">
|
|
1548
|
+
{evidence.logs.map((log, index) => (
|
|
1549
|
+
<HookLogEvidenceBlock key={`${log.pod}/${log.container}/${log.previous ? 'previous' : 'current'}/${index}`} log={log} />
|
|
1550
|
+
))}
|
|
1551
|
+
</div>
|
|
1552
|
+
)}
|
|
1553
|
+
|
|
1554
|
+
{evidence.errors && evidence.errors.length > 0 && (
|
|
1555
|
+
<div className="space-y-1 text-theme-text-tertiary">
|
|
1556
|
+
{evidence.errors.map((error) => (
|
|
1557
|
+
<div key={error}>Evidence read error: {error}</div>
|
|
1558
|
+
))}
|
|
1559
|
+
</div>
|
|
1560
|
+
)}
|
|
1561
|
+
</div>
|
|
1562
|
+
)}
|
|
1563
|
+
</div>
|
|
1564
|
+
)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function HookLogEvidenceBlock({ log }: { log: HookLogEvidence }) {
|
|
1568
|
+
const visibleLines = (log.lines || []).slice(0, 8)
|
|
1569
|
+
|
|
1570
|
+
return (
|
|
1571
|
+
<div className="rounded bg-theme-base/40 px-2 py-2">
|
|
1572
|
+
<div className="mb-1 flex flex-wrap items-center gap-2 text-theme-text-secondary">
|
|
1573
|
+
<span className="font-medium">Logs</span>
|
|
1574
|
+
<span className="break-all">{log.pod}/{log.container}</span>
|
|
1575
|
+
{log.previous && <span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">previous</span>}
|
|
1576
|
+
{log.fallback && <span className="text-theme-text-tertiary">fallback tail</span>}
|
|
1577
|
+
</div>
|
|
1578
|
+
{log.error ? (
|
|
1579
|
+
<div className="text-theme-text-tertiary">{log.error}</div>
|
|
1580
|
+
) : (
|
|
1581
|
+
<>
|
|
1582
|
+
<pre className="max-h-44 overflow-auto whitespace-pre-wrap rounded bg-theme-base px-2 py-1 font-mono text-[11px] leading-5 text-theme-text-secondary">
|
|
1583
|
+
{visibleLines.join('\n')}
|
|
1584
|
+
</pre>
|
|
1585
|
+
{log.lines && log.lines.length > visibleLines.length && (
|
|
1586
|
+
<div className="mt-1 text-theme-text-tertiary">+{log.lines.length - visibleLines.length} more lines in API response</div>
|
|
1587
|
+
)}
|
|
1588
|
+
</>
|
|
1589
|
+
)}
|
|
832
1590
|
</div>
|
|
833
1591
|
)
|
|
834
1592
|
}
|