@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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +167 -56
  3. package/src/RadarApp.tsx +18 -1
  4. package/src/api/client.ts +173 -6
  5. package/src/components/NamespaceSwitcher.tsx +52 -30
  6. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  7. package/src/components/gitops/GitOpsView.tsx +1 -10
  8. package/src/components/helm/HelmReleaseDrawer.tsx +802 -44
  9. package/src/components/helm/HelmView.tsx +85 -16
  10. package/src/components/helm/ManifestDiffViewer.tsx +15 -4
  11. package/src/components/helm/OwnedResources.tsx +14 -50
  12. package/src/components/helm/RevisionHistory.tsx +50 -2
  13. package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
  14. package/src/components/helm/ValuesViewer.tsx +41 -11
  15. package/src/components/home/TrafficSummary.tsx +2 -2
  16. package/src/components/home/mcpToolCatalog.ts +10 -10
  17. package/src/components/nav/PrimaryNavRail.tsx +1 -1
  18. package/src/components/portforward/PortForwardButton.tsx +69 -25
  19. package/src/components/portforward/PortForwardManager.tsx +18 -4
  20. package/src/components/resources/ResourcesView.tsx +42 -1
  21. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  22. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  23. package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
  24. package/src/components/ui/UpdateNotification.tsx +5 -10
  25. package/src/components/ui/command-items.ts +1 -1
  26. package/src/components/workload/WorkloadView.tsx +52 -7
  27. package/src/context/ConnectionContext.tsx +29 -2
  28. package/src/contexts/CapabilitiesContext.tsx +8 -0
  29. package/src/hooks/useDocumentTitle.ts +25 -0
  30. package/src/main.tsx +5 -3
  31. package/src/utils/auditBadges.ts +53 -0
  32. 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 = drawerWidth
125
- }, [drawerWidth])
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
- setDrawerWidth(Math.max(MIN_WIDTH, Math.min(newWidth, maxWidth)))
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 (!upgradeInfo?.latestVersion) return
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
- upgradeInfo.latestVersion,
246
- upgradeInfo.repositoryName,
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 ${upgradeInfo.latestVersion}`,
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: 'Diff', icon: FileText })
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: drawerWidth, top: headerHeight, height: `calc(100vh - ${headerHeight}px - ${dockInset}px)` }}
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
- <Tooltip content={upgradeInfo.error}>
367
- <span
368
- className="badge bg-theme-hover/50 text-theme-text-secondary"
369
- >
370
- upstream unknown
371
- </span>
372
- </Tooltip>
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 manifest diffs">
514
- <ManifestDiffViewer
515
- diff={diffData?.diff || ''}
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 ${upgradeInfo?.latestVersion}?`}
693
+ message={`Upgrade "${release.name}" to version ${targetVersion}?`}
584
694
  details={upgradeProgress.length === 0
585
- ? `This will upgrade the chart from version ${upgradeInfo?.currentVersion} to ${upgradeInfo?.latestVersion}. Your existing values will be preserved. The upgrade will be applied to your cluster immediately.`
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="Upgrade"
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} -&gt; {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">-&gt;</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} -&gt; {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
- <p className="text-sm text-theme-text-secondary mb-4">
798
- Helm hooks are executed at specific points during the release lifecycle.
799
- </p>
800
- {hooks.map((hook) => (
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 items-center gap-2 mt-1 text-xs text-theme-text-tertiary">
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
  }