@skyhook-io/radar-app 1.8.2 → 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.
@@ -4,15 +4,15 @@ import { FetchResult, useDockReservedHeight, compareVersions } from '@skyhook-io
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, AlertTriangle, RotateCcw, Clock } 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, useHelmReleaseVersions, 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, HelmOperation } 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'
@@ -34,6 +34,7 @@ interface HelmReleaseDrawerProps {
34
34
  }
35
35
 
36
36
  type TabId = 'overview' | 'history' | 'manifest' | 'values' | 'resources' | 'hooks' | 'diff'
37
+ type CompareMode = 'summary' | 'values' | 'manifest' | 'notes' | 'resources'
37
38
 
38
39
  const MIN_WIDTH = 500
39
40
  const MAX_WIDTH_PERCENT = 0.8
@@ -44,10 +45,12 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
44
45
  const [activeTab, setActiveTab] = useState<TabId>('overview')
45
46
  const [copied, setCopied] = useState<string | null>(null)
46
47
  const [drawerWidth, setDrawerWidth] = useState(DEFAULT_WIDTH)
48
+ const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? DEFAULT_WIDTH : window.innerWidth))
47
49
  const [isResizing, setIsResizing] = useState(false)
48
50
  const [selectedRevision, setSelectedRevision] = useState<number | undefined>(undefined)
49
51
  const [showAllValues, setShowAllValues] = useState(false)
50
52
  const [diffRevisions, setDiffRevisions] = useState<{ rev1: number; rev2: number } | null>(null)
53
+ const [compareMode, setCompareMode] = useState<CompareMode>('summary')
51
54
  const [rollbackRevision, setRollbackRevision] = useState<number | null>(null)
52
55
  const [showUninstallConfirm, setShowUninstallConfirm] = useState(false)
53
56
  const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false)
@@ -84,6 +87,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
84
87
  release.name,
85
88
  showAllValues,
86
89
  canViewSensitive,
90
+ selectedRevision,
87
91
  )
88
92
 
89
93
  // Fetch diff if comparing revisions
@@ -92,7 +96,29 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
92
96
  release.name,
93
97
  diffRevisions?.rev1 || 0,
94
98
  diffRevisions?.rev2 || 0,
95
- 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',
96
122
  )
97
123
 
98
124
  // Lazy check for upgrade availability
@@ -130,13 +156,24 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
130
156
  return () => document.removeEventListener('keydown', handleKeyDown)
131
157
  }, [onClose])
132
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
+
133
170
  // Resize handlers
134
171
  const handleResizeStart = useCallback((e: React.MouseEvent) => {
135
172
  e.preventDefault()
136
173
  setIsResizing(true)
137
174
  resizeStartX.current = e.clientX
138
- resizeStartWidth.current = drawerWidth
139
- }, [drawerWidth])
175
+ resizeStartWidth.current = renderedDrawerWidth
176
+ }, [renderedDrawerWidth])
140
177
 
141
178
  useEffect(() => {
142
179
  if (!isResizing) return
@@ -144,11 +181,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
144
181
  document.body.style.cursor = 'ew-resize'
145
182
  document.body.style.userSelect = 'none'
146
183
 
147
- const maxWidth = window.innerWidth * MAX_WIDTH_PERCENT
148
184
  const handleMouseMove = (e: MouseEvent) => {
149
185
  const deltaX = resizeStartX.current - e.clientX
150
186
  const newWidth = resizeStartWidth.current + deltaX
151
- 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)))
152
191
  }
153
192
  const handleMouseUp = () => setIsResizing(false)
154
193
  document.addEventListener('mousemove', handleMouseMove)
@@ -176,6 +215,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
176
215
 
177
216
  const handleCompareRevisions = (rev1: number, rev2: number) => {
178
217
  setDiffRevisions({ rev1, rev2 })
218
+ setCompareMode('summary')
179
219
  switchTab('diff')
180
220
  }
181
221
 
@@ -310,7 +350,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
310
350
 
311
351
  // Add diff tab only when comparing
312
352
  if (diffRevisions) {
313
- tabs.push({ id: 'diff', label: 'Diff', icon: FileText })
353
+ tabs.push({ id: 'diff', label: 'Compare', icon: GitCompare })
314
354
  }
315
355
 
316
356
  return (
@@ -320,7 +360,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
320
360
  TRANSITION_DRAWER,
321
361
  isOpen ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
322
362
  )}
323
- 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)` }}
324
364
  >
325
365
  {/* Resize handle */}
326
366
  <div
@@ -409,7 +449,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
409
449
  )}
410
450
  >
411
451
  <Link2 className="w-3 h-3" />
412
- source not tracked
452
+ upgrade source not tracked
413
453
  </button>
414
454
  </Tooltip>
415
455
  ) : (
@@ -516,6 +556,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
516
556
  <HelmOperationBanner
517
557
  operation={releaseDetail.lastOperation}
518
558
  managedByFluxHelmRelease={releaseDetail.managedByFluxHelmRelease}
559
+ hookDiagnostics={releaseDetail.hookDiagnostics}
519
560
  />
520
561
  {activeTab === 'overview' && (
521
562
  <OverviewTab release={releaseDetail} onCopy={copyToClipboard} copied={copied} />
@@ -552,6 +593,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
552
593
  copied={copied === 'values'}
553
594
  namespace={helmNamespace}
554
595
  name={release.name}
596
+ revision={selectedRevision}
597
+ currentRevision={releaseDetail.revision}
555
598
  onApplySuccess={() => refetch()}
556
599
  />
557
600
  </RoleGatedPanel>
@@ -563,17 +606,27 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
563
606
  />
564
607
  )}
565
608
  {activeTab === 'hooks' && (
566
- <HooksTab hooks={releaseDetail.hooks || []} />
609
+ <HooksTab hooks={releaseDetail.hooks || []} hookDiagnostics={releaseDetail.hookDiagnostics || []} />
567
610
  )}
568
611
  {activeTab === 'diff' && diffRevisions && (
569
- <RoleGatedPanel min="member" feature="release manifest diffs">
570
- <ManifestDiffViewer
571
- diff={diffData?.diff || ''}
572
- isLoading={diffLoading}
612
+ <RoleGatedPanel min="member" feature="release revision comparison">
613
+ <HelmRevisionCompareView
614
+ revisions={releaseDetail.history}
573
615
  revision1={diffRevisions.rev1}
574
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}
575
627
  onClose={() => {
576
628
  setDiffRevisions(null)
629
+ setCompareMode('summary')
577
630
  setActiveTab('history')
578
631
  }}
579
632
  />
@@ -730,6 +783,278 @@ function mergeHelmOperations(operations: HelmOperation[] | undefined, lastOperat
730
783
  return merged
731
784
  }
732
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
+
733
1058
  function helmOperationKey(operation: HelmOperation): string {
734
1059
  return [
735
1060
  operation.kind,
@@ -744,19 +1069,41 @@ function helmOperationKey(operation: HelmOperation): string {
744
1069
  function HelmOperationBanner({
745
1070
  operation,
746
1071
  managedByFluxHelmRelease,
1072
+ hookDiagnostics,
747
1073
  }: {
748
1074
  operation?: HelmOperation
749
1075
  managedByFluxHelmRelease?: string
1076
+ hookDiagnostics?: HookDiagnostic[]
750
1077
  }) {
751
- if (!operation || !shouldShowOperationBanner(operation)) {
1078
+ const primaryHookDiagnostic = hookDiagnostics?.[0]
1079
+ if ((!operation || !shouldShowOperationBanner(operation)) && !primaryHookDiagnostic) {
752
1080
  return null
753
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
+ }
754
1099
 
755
1100
  const isFailure = operation.status === 'failed'
756
1101
  const isPending = operation.status === 'stuck_pending'
757
1102
  const tone: 'error' | 'warning' | 'info' = isFailure ? 'error' : operation.kind === 'rollback' ? 'info' : 'warning'
758
1103
  const Icon = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' ? RotateCcw : isPending ? Clock : AlertTriangle
759
1104
  const title = operationTitle(operation)
1105
+ const statusLabel = operation.status.replace(/_/g, ' ')
1106
+ const showStatusBadge = !(operation.status === 'failed' && title.toLowerCase().includes('failed'))
760
1107
 
761
1108
  return (
762
1109
  <div className="m-4 mb-0 card-inner-lg">
@@ -765,7 +1112,9 @@ function HelmOperationBanner({
765
1112
  <div className="min-w-0 flex-1">
766
1113
  <div className="flex flex-wrap items-center gap-2">
767
1114
  <span className="text-sm font-medium text-theme-text-primary">{title}</span>
768
- <span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{operation.status.replace(/_/g, ' ')}</span>
1115
+ {showStatusBadge && (
1116
+ <span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{statusLabel}</span>
1117
+ )}
769
1118
  <OperationRevisionChips operation={operation} />
770
1119
  </div>
771
1120
  <p className="mt-1 text-sm text-theme-text-secondary">{operation.message}</p>
@@ -779,6 +1128,7 @@ function HelmOperationBanner({
779
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.
780
1129
  </p>
781
1130
  )}
1131
+ {primaryHookDiagnostic && <HookSignal diagnostic={primaryHookDiagnostic} />}
782
1132
  {managedByFluxHelmRelease && (
783
1133
  <p className="mt-1 text-xs text-theme-text-tertiary">
784
1134
  This release is managed by Flux HelmRelease {managedByFluxHelmRelease}; direct Helm changes may be reconciled back.
@@ -790,6 +1140,27 @@ function HelmOperationBanner({
790
1140
  )
791
1141
  }
792
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
+
793
1164
  function shouldShowOperationBanner(operation: HelmOperation): boolean {
794
1165
  return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'failed' || operation.status === 'stuck_pending'
795
1166
  }
@@ -972,9 +1343,10 @@ function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
972
1343
  // Hooks tab content
973
1344
  interface HooksTabProps {
974
1345
  hooks: HelmHook[]
1346
+ hookDiagnostics: HookDiagnostic[]
975
1347
  }
976
1348
 
977
- function HooksTab({ hooks }: HooksTabProps) {
1349
+ function HooksTab({ hooks, hookDiagnostics }: HooksTabProps) {
978
1350
  if (hooks.length === 0) {
979
1351
  return (
980
1352
  <div className="flex flex-col items-center justify-center h-48 text-theme-text-tertiary">
@@ -1006,23 +1378,31 @@ function HooksTab({ hooks }: HooksTabProps) {
1006
1378
  return SEVERITY_BADGE.neutral
1007
1379
  }
1008
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
+
1009
1387
  return (
1010
1388
  <div className="p-4 space-y-3">
1011
- <p className="text-sm text-theme-text-secondary mb-4">
1012
- Helm hooks are executed at specific points during the release lifecycle.
1013
- </p>
1014
- {hooks.map((hook) => (
1015
- <div key={hook.name} className="bg-theme-elevated/30 rounded-lg p-4">
1016
- <div className="flex items-start justify-between mb-2">
1017
- <div>
1018
- <div className="flex items-center gap-2">
1019
- <span className="text-theme-text-primary font-medium">{hook.name}</span>
1020
- <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">
1021
1399
  {hook.kind}
1022
1400
  </span>
1023
1401
  </div>
1024
- <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>}
1025
1404
  <span>Weight: {hook.weight}</span>
1405
+ {hook.path && <span className="break-all">Path: {hook.path}</span>}
1026
1406
  </div>
1027
1407
  </div>
1028
1408
  {hook.status && (
@@ -1041,8 +1421,172 @@ function HooksTab({ hooks }: HooksTabProps) {
1041
1421
  </span>
1042
1422
  ))}
1043
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} />}
1044
1453
  </div>
1045
- ))}
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
+ )}
1046
1590
  </div>
1047
1591
  )
1048
1592
  }