@skyhook-io/radar-app 1.8.3 → 1.8.6

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 +5 -5
  2. package/src/App.tsx +256 -94
  3. package/src/RadarApp.tsx +4 -1
  4. package/src/api/client.metrics.test.ts +106 -0
  5. package/src/api/client.ts +147 -20
  6. package/src/components/ConnectionErrorView.tsx +1 -1
  7. package/src/components/ContextSwitcher.tsx +5 -1
  8. package/src/components/NamespaceSwitcher.tsx +21 -300
  9. package/src/components/applications/ApplicationsView.tsx +22 -7
  10. package/src/components/audit/AuditView.tsx +11 -2
  11. package/src/components/cost/CostView.tsx +12 -2
  12. package/src/components/gitops/GitOpsView.tsx +22 -7
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +4 -4
  17. package/src/components/home/ClusterHealthCard.tsx +6 -1
  18. package/src/components/home/HomeView.tsx +20 -7
  19. package/src/components/home/mcpToolCatalog.ts +1 -1
  20. package/src/components/issues/IssuesPane.tsx +29 -18
  21. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  22. package/src/components/resources/ResourcesView.tsx +3 -0
  23. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/PodRenderer.tsx +10 -4
  25. package/src/components/timeline/TimelineView.tsx +26 -2
  26. package/src/components/traffic/TrafficView.tsx +17 -10
  27. package/src/components/ui/Markdown.tsx +2 -2
  28. package/src/components/ui/Omnibar.tsx +1 -1
  29. package/src/components/workload/WorkloadView.tsx +5 -1
  30. package/src/filter/FilterLocationBridge.tsx +30 -0
  31. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  32. package/src/index.ts +15 -0
@@ -4,25 +4,24 @@ 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, GitCompare, Plus, Minus, Equal } from 'lucide-react'
7
+ import { X, Copy, Check, RefreshCw, Package, Code, History, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch, AlertTriangle, RotateCcw, Clock, GitCompare, ExternalLink } from 'lucide-react'
8
8
  import { useNavigate } from 'react-router-dom'
9
9
  import { clsx } from 'clsx'
10
- import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmValuesDiff, useHelmNotesDiff, useHelmResourceDiff, useHelmUpgradeInfo, useHelmReleaseVersions, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
10
+ import { useHelmRelease, useHelmManifest, useHelmValues, 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, HelmRevision, ResourceDiff, HookDiagnostic, HookLogEvidence } from '../../types'
16
- import type { NavigateToResource } from '../../utils/navigation'
15
+ import type { SelectedHelmRelease, HelmHook, ChartDependency, HelmOperation, HelmOperationInsight, HelmOwnedResource, HookDiagnostic, HookLogEvidence } from '../../types'
16
+ import { apiVersionToGroup, kindToPlural, type NavigateToResource } from '../../utils/navigation'
17
17
  import { formatDate } from './helm-utils'
18
- import { getHelmStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
18
+ import { getHelmStatusColor, getKindBadgeColor, getResourceStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
19
19
  import { useCanHelmAct, useCloudRole } from '../../api/client'
20
20
  import { RoleGatedPanel } from './RoleGatedPanel'
21
21
  import { RevisionHistory } from './RevisionHistory'
22
22
  import { ManifestViewer } from './ManifestViewer'
23
23
  import { ValuesViewer } from './ValuesViewer'
24
24
  import { OwnedResources } from './OwnedResources'
25
- import { ManifestDiffViewer } from './ManifestDiffViewer'
26
25
  import { TrackChartSourceDialog } from './TrackChartSourceDialog'
27
26
 
28
27
  interface HelmReleaseDrawerProps {
@@ -33,8 +32,7 @@ interface HelmReleaseDrawerProps {
33
32
  isOpen?: boolean
34
33
  }
35
34
 
36
- type TabId = 'overview' | 'history' | 'manifest' | 'values' | 'resources' | 'hooks' | 'diff'
37
- type CompareMode = 'summary' | 'values' | 'manifest' | 'notes' | 'resources'
35
+ type TabId = 'overview' | 'history' | 'manifest' | 'values' | 'resources' | 'hooks'
38
36
 
39
37
  const MIN_WIDTH = 500
40
38
  const MAX_WIDTH_PERCENT = 0.8
@@ -49,8 +47,6 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
49
47
  const [isResizing, setIsResizing] = useState(false)
50
48
  const [selectedRevision, setSelectedRevision] = useState<number | undefined>(undefined)
51
49
  const [showAllValues, setShowAllValues] = useState(false)
52
- const [diffRevisions, setDiffRevisions] = useState<{ rev1: number; rev2: number } | null>(null)
53
- const [compareMode, setCompareMode] = useState<CompareMode>('summary')
54
50
  const [rollbackRevision, setRollbackRevision] = useState<number | null>(null)
55
51
  const [showUninstallConfirm, setShowUninstallConfirm] = useState(false)
56
52
  const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false)
@@ -90,37 +86,6 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
90
86
  selectedRevision,
91
87
  )
92
88
 
93
- // Fetch diff if comparing revisions
94
- const { data: diffData, isLoading: diffLoading } = useHelmManifestDiff(
95
- helmNamespace,
96
- release.name,
97
- diffRevisions?.rev1 || 0,
98
- diffRevisions?.rev2 || 0,
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',
122
- )
123
-
124
89
  // Lazy check for upgrade availability
125
90
  const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError } = useHelmUpgradeInfo(
126
91
  helmNamespace,
@@ -213,11 +178,16 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
213
178
  startViewTransitionSafe(() => flushSync(() => setActiveTab(tab)))
214
179
  }, [])
215
180
 
216
- const handleCompareRevisions = (rev1: number, rev2: number) => {
217
- setDiffRevisions({ rev1, rev2 })
218
- setCompareMode('summary')
219
- switchTab('diff')
220
- }
181
+ const handleCompareRevisions = useCallback((rev1: number, rev2: number) => {
182
+ const params = new URLSearchParams()
183
+ const globalNamespaces = new URLSearchParams(window.location.search).get('namespaces')
184
+ if (globalNamespaces) params.set('namespaces', globalNamespaces)
185
+ params.set('release', `${release.namespace}/${release.name}`)
186
+ if (release.storageNamespace) params.set('releaseStorage', release.storageNamespace)
187
+ params.set('revision1', String(rev1))
188
+ params.set('revision2', String(rev2))
189
+ navigate({ pathname: '/helm/compare', search: params.toString() })
190
+ }, [navigate, release.name, release.namespace, release.storageNamespace])
221
191
 
222
192
  const handleViewRevision = (revision: number) => {
223
193
  setSelectedRevision(revision)
@@ -348,11 +318,6 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
348
318
  { id: 'hooks', label: 'Hooks', icon: Anchor },
349
319
  ]
350
320
 
351
- // Add diff tab only when comparing
352
- if (diffRevisions) {
353
- tabs.push({ id: 'diff', label: 'Compare', icon: GitCompare })
354
- }
355
-
356
321
  return (
357
322
  <div
358
323
  className={clsx(
@@ -555,8 +520,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
555
520
  <>
556
521
  <HelmOperationBanner
557
522
  operation={releaseDetail.lastOperation}
523
+ operationInsight={releaseDetail.operationInsight}
524
+ releaseNamespace={releaseDetail.namespace}
558
525
  managedByFluxHelmRelease={releaseDetail.managedByFluxHelmRelease}
559
526
  hookDiagnostics={releaseDetail.hookDiagnostics}
527
+ canCompare={canViewSensitive}
528
+ onCompare={handleCompareRevisions}
529
+ onNavigateToResource={onNavigateToResource}
560
530
  />
561
531
  {activeTab === 'overview' && (
562
532
  <OverviewTab release={releaseDetail} onCopy={copyToClipboard} copied={copied} />
@@ -608,30 +578,6 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
608
578
  {activeTab === 'hooks' && (
609
579
  <HooksTab hooks={releaseDetail.hooks || []} hookDiagnostics={releaseDetail.hookDiagnostics || []} />
610
580
  )}
611
- {activeTab === 'diff' && diffRevisions && (
612
- <RoleGatedPanel min="member" feature="release revision comparison">
613
- <HelmRevisionCompareView
614
- revisions={releaseDetail.history}
615
- revision1={diffRevisions.rev1}
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}
627
- onClose={() => {
628
- setDiffRevisions(null)
629
- setCompareMode('summary')
630
- setActiveTab('history')
631
- }}
632
- />
633
- </RoleGatedPanel>
634
- )}
635
581
  </>
636
582
  )}
637
583
  </div>
@@ -783,278 +729,6 @@ function mergeHelmOperations(operations: HelmOperation[] | undefined, lastOperat
783
729
  return merged
784
730
  }
785
731
 
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
732
  function helmOperationKey(operation: HelmOperation): string {
1059
733
  return [
1060
734
  operation.kind,
@@ -1068,12 +742,22 @@ function helmOperationKey(operation: HelmOperation): string {
1068
742
 
1069
743
  function HelmOperationBanner({
1070
744
  operation,
745
+ operationInsight,
746
+ releaseNamespace,
1071
747
  managedByFluxHelmRelease,
1072
748
  hookDiagnostics,
749
+ canCompare,
750
+ onCompare,
751
+ onNavigateToResource,
1073
752
  }: {
1074
753
  operation?: HelmOperation
754
+ operationInsight?: HelmOperationInsight
755
+ releaseNamespace: string
1075
756
  managedByFluxHelmRelease?: string
1076
757
  hookDiagnostics?: HookDiagnostic[]
758
+ canCompare?: boolean
759
+ onCompare?: (rev1: number, rev2: number) => void
760
+ onNavigateToResource?: NavigateToResource
1077
761
  }) {
1078
762
  const primaryHookDiagnostic = hookDiagnostics?.[0]
1079
763
  if ((!operation || !shouldShowOperationBanner(operation)) && !primaryHookDiagnostic) {
@@ -1104,6 +788,8 @@ function HelmOperationBanner({
1104
788
  const title = operationTitle(operation)
1105
789
  const statusLabel = operation.status.replace(/_/g, ' ')
1106
790
  const showStatusBadge = !(operation.status === 'failed' && title.toLowerCase().includes('failed'))
791
+ const rawMessage = operation.rawMessage?.trim()
792
+ const hasRawMessage = !!rawMessage && rawMessage !== operation.message.trim()
1107
793
 
1108
794
  return (
1109
795
  <div className="m-4 mb-0 card-inner-lg">
@@ -1118,16 +804,35 @@ function HelmOperationBanner({
1118
804
  <OperationRevisionChips operation={operation} />
1119
805
  </div>
1120
806
  <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>
807
+ {hasRawMessage && (
808
+ <details className="mt-2">
809
+ <summary className="cursor-pointer select-none text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary">
810
+ Show raw Helm error
811
+ </summary>
812
+ <pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-theme-base/60 p-2 font-mono text-[11px] leading-relaxed text-theme-text-tertiary">
813
+ {rawMessage}
814
+ </pre>
815
+ </details>
816
+ )}
817
+ {operation.failureDescription && !hasRawMessage && (
818
+ <Tooltip content={operation.failureDescription} wrapperClassName="mt-1 flex">
819
+ <span className="line-clamp-2 text-xs text-theme-text-tertiary">
820
+ {operation.failureDescription}
821
+ </span>
822
+ </Tooltip>
1125
823
  )}
1126
824
  {operation.kind === 'upgrade_rolled_back' && (
1127
825
  <p className="mt-1 text-xs text-theme-text-tertiary">
1128
826
  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
827
  </p>
1130
828
  )}
829
+ <OperationInsightSignals
830
+ insight={operationInsight}
831
+ releaseNamespace={releaseNamespace}
832
+ canCompare={Boolean(canCompare)}
833
+ onCompare={onCompare}
834
+ onNavigateToResource={onNavigateToResource}
835
+ />
1131
836
  {primaryHookDiagnostic && <HookSignal diagnostic={primaryHookDiagnostic} />}
1132
837
  {managedByFluxHelmRelease && (
1133
838
  <p className="mt-1 text-xs text-theme-text-tertiary">
@@ -1140,6 +845,125 @@ function HelmOperationBanner({
1140
845
  )
1141
846
  }
1142
847
 
848
+ function OperationInsightSignals({
849
+ insight,
850
+ releaseNamespace,
851
+ canCompare,
852
+ onCompare,
853
+ onNavigateToResource,
854
+ }: {
855
+ insight?: HelmOperationInsight
856
+ releaseNamespace: string
857
+ canCompare: boolean
858
+ onCompare?: (rev1: number, rev2: number) => void
859
+ onNavigateToResource?: NavigateToResource
860
+ }) {
861
+ const compare = insight?.suggestedCompare
862
+ const primaryResource = insight?.primaryResource
863
+ const relatedCount = primaryResource
864
+ ? Math.max(0, (insight?.signalCount ?? 0) - 1)
865
+ : insight?.relatedResources?.length ?? 0
866
+ const showCompare = Boolean(compare && canCompare && onCompare)
867
+ if (!primaryResource && !showCompare) {
868
+ return null
869
+ }
870
+
871
+ return (
872
+ <div className="mt-2 space-y-2">
873
+ {primaryResource && (
874
+ <PrimaryOperationResourceSignal
875
+ resource={primaryResource}
876
+ releaseNamespace={releaseNamespace}
877
+ relatedCount={relatedCount}
878
+ onNavigateToResource={onNavigateToResource}
879
+ />
880
+ )}
881
+ {showCompare && compare && (
882
+ <Tooltip content={compare.reason || 'Compare the two Helm revisions'}>
883
+ <button
884
+ type="button"
885
+ onClick={() => onCompare?.(compare.revision1, compare.revision2)}
886
+ className="inline-flex items-center gap-1.5 rounded-md border border-theme-border-light bg-theme-elevated px-2.5 py-1.5 text-xs font-medium text-theme-text-primary shadow-theme-sm transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
887
+ >
888
+ <GitCompare className="h-3.5 w-3.5" />
889
+ Compare rev {compare.revision1} to {compare.revision2}
890
+ </button>
891
+ </Tooltip>
892
+ )}
893
+ </div>
894
+ )
895
+ }
896
+
897
+ function PrimaryOperationResourceSignal({
898
+ resource,
899
+ releaseNamespace,
900
+ relatedCount,
901
+ onNavigateToResource,
902
+ }: {
903
+ resource: HelmOwnedResource
904
+ releaseNamespace: string
905
+ relatedCount: number
906
+ onNavigateToResource?: NavigateToResource
907
+ }) {
908
+ const canNavigate = Boolean(onNavigateToResource)
909
+ const resourceLabel = `${resource.kind}/${resource.name}`
910
+ const resourceTarget = resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name
911
+ const showNamespace = Boolean(resource.namespace && resource.namespace !== releaseNamespace)
912
+ const resourceTooltip = `Open ${resource.kind} ${resourceTarget}`
913
+ const showInlineReady = Boolean(resource.ready && !resource.summary)
914
+ const openResource = () => {
915
+ onNavigateToResource?.({
916
+ kind: kindToPlural(resource.kind),
917
+ namespace: resource.namespace,
918
+ name: resource.name,
919
+ group: apiVersionToGroup(resource.apiVersion),
920
+ })
921
+ }
922
+
923
+ return (
924
+ <div className="rounded-md bg-theme-base/50 p-2 text-xs">
925
+ <div className="flex flex-wrap items-center gap-2">
926
+ <span className="font-medium text-theme-text-secondary">Likely resource to inspect</span>
927
+ {canNavigate ? (
928
+ <Tooltip content={resourceTooltip}>
929
+ <button
930
+ type="button"
931
+ onClick={openResource}
932
+ aria-label={resourceTooltip}
933
+ className={clsx(
934
+ 'badge-sm max-w-full min-w-0 transition-colors hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1 focus:ring-offset-theme-base',
935
+ getKindBadgeColor(resource.kind)
936
+ )}
937
+ >
938
+ <span className="min-w-0 truncate">{resourceLabel}</span>
939
+ <ExternalLink className="h-3 w-3 shrink-0 opacity-70" />
940
+ </button>
941
+ </Tooltip>
942
+ ) : (
943
+ <span className={clsx('badge-sm max-w-full min-w-0', getKindBadgeColor(resource.kind))}>
944
+ <span className="min-w-0 truncate">{resourceLabel}</span>
945
+ </span>
946
+ )}
947
+ {showNamespace && <span className="text-theme-text-tertiary">ns/{resource.namespace}</span>}
948
+ {showInlineReady && <span className="font-mono text-theme-text-secondary">{resource.ready}</span>}
949
+ {resource.status && (
950
+ <span className={clsx('badge-sm', getResourceStatusColor(resource.status))}>{resource.status}</span>
951
+ )}
952
+ </div>
953
+ {(resource.issue || resource.summary || resource.message || relatedCount > 0) && (
954
+ <div className="mt-1 text-theme-text-tertiary">
955
+ {[
956
+ resource.issue || resource.summary || resource.message,
957
+ relatedCount > 0 ? `${relatedCount} more affected resource${relatedCount === 1 ? '' : 's'} in Resources tab` : null,
958
+ ]
959
+ .filter(Boolean)
960
+ .join(' - ')}
961
+ </div>
962
+ )}
963
+ </div>
964
+ )
965
+ }
966
+
1143
967
  function hookDiagnosticTone(diagnostic: HookDiagnostic): 'error' | 'warning' {
1144
968
  return diagnostic.phase.toLowerCase() === 'failed' ? 'error' : 'warning'
1145
969
  }
@@ -1220,12 +1044,15 @@ interface OverviewTabProps {
1220
1044
  notes: string
1221
1045
  readme?: string
1222
1046
  dependencies?: ChartDependency[]
1047
+ lastOperation?: HelmOperation
1223
1048
  }
1224
1049
  onCopy: (text: string, key: string) => void
1225
1050
  copied: string | null
1226
1051
  }
1227
1052
 
1228
1053
  function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
1054
+ const showDescription = !!release.description && !isDuplicateOperationRawDescription(release.description, release.lastOperation)
1055
+
1229
1056
  return (
1230
1057
  <div className="p-4 space-y-4">
1231
1058
  {/* Chart info */}
@@ -1256,7 +1083,7 @@ function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
1256
1083
  </div>
1257
1084
 
1258
1085
  {/* Description */}
1259
- {release.description && (
1086
+ {showDescription && (
1260
1087
  <div className="bg-theme-elevated/30 rounded-lg p-4">
1261
1088
  <h3 className="text-sm font-medium text-theme-text-secondary mb-2">Description</h3>
1262
1089
  <p className="text-sm text-theme-text-secondary">{release.description}</p>
@@ -1340,6 +1167,16 @@ function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
1340
1167
  )
1341
1168
  }
1342
1169
 
1170
+ function isDuplicateOperationRawDescription(description: string, operation?: HelmOperation): boolean {
1171
+ const desc = normalizeComparableText(description)
1172
+ const raw = normalizeComparableText(operation?.rawMessage || '')
1173
+ return desc !== '' && raw.includes(desc)
1174
+ }
1175
+
1176
+ function normalizeComparableText(value: string): string {
1177
+ return value.trim().replace(/\s+/g, ' ')
1178
+ }
1179
+
1343
1180
  // Hooks tab content
1344
1181
  interface HooksTabProps {
1345
1182
  hooks: HelmHook[]