@skyhook-io/radar-app 1.8.2 → 1.8.5

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 (43) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +412 -146
  3. package/src/RadarApp.tsx +21 -1
  4. package/src/api/client.ts +144 -12
  5. package/src/components/ConnectionErrorView.tsx +1 -1
  6. package/src/components/ContextSwitcher.tsx +5 -1
  7. package/src/components/NamespaceSwitcher.tsx +21 -278
  8. package/src/components/applications/ApplicationsView.tsx +13 -1
  9. package/src/components/audit/AuditView.tsx +11 -2
  10. package/src/components/cost/CostView.tsx +12 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  12. package/src/components/gitops/GitOpsView.tsx +23 -17
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +18 -7
  17. package/src/components/helm/OwnedResources.tsx +14 -50
  18. package/src/components/helm/RevisionHistory.tsx +9 -5
  19. package/src/components/helm/ValuesViewer.tsx +41 -11
  20. package/src/components/home/ClusterHealthCard.tsx +6 -1
  21. package/src/components/home/HomeView.tsx +12 -1
  22. package/src/components/home/mcpToolCatalog.ts +8 -8
  23. package/src/components/issues/IssuesPane.tsx +29 -18
  24. package/src/components/portforward/PortForwardButton.tsx +69 -25
  25. package/src/components/portforward/PortForwardManager.tsx +18 -4
  26. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  27. package/src/components/resources/ResourcesView.tsx +45 -1
  28. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  29. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  30. package/src/components/timeline/TimelineView.tsx +26 -2
  31. package/src/components/traffic/TrafficView.tsx +17 -10
  32. package/src/components/ui/Markdown.tsx +2 -2
  33. package/src/components/ui/Omnibar.tsx +1 -1
  34. package/src/components/ui/UpdateNotification.tsx +5 -10
  35. package/src/components/workload/WorkloadView.tsx +57 -8
  36. package/src/contexts/CapabilitiesContext.tsx +8 -0
  37. package/src/filter/FilterLocationBridge.tsx +30 -0
  38. package/src/hooks/useDocumentTitle.ts +25 -0
  39. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  40. package/src/index.ts +15 -0
  41. package/src/main.tsx +5 -3
  42. package/src/utils/auditBadges.ts +53 -0
  43. package/src/utils/navigation.ts +5 -3
@@ -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 } 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, 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 } 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,7 +32,7 @@ interface HelmReleaseDrawerProps {
33
32
  isOpen?: boolean
34
33
  }
35
34
 
36
- type TabId = 'overview' | 'history' | 'manifest' | 'values' | 'resources' | 'hooks' | 'diff'
35
+ type TabId = 'overview' | 'history' | 'manifest' | 'values' | 'resources' | 'hooks'
37
36
 
38
37
  const MIN_WIDTH = 500
39
38
  const MAX_WIDTH_PERCENT = 0.8
@@ -44,10 +43,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
44
43
  const [activeTab, setActiveTab] = useState<TabId>('overview')
45
44
  const [copied, setCopied] = useState<string | null>(null)
46
45
  const [drawerWidth, setDrawerWidth] = useState(DEFAULT_WIDTH)
46
+ const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? DEFAULT_WIDTH : window.innerWidth))
47
47
  const [isResizing, setIsResizing] = useState(false)
48
48
  const [selectedRevision, setSelectedRevision] = useState<number | undefined>(undefined)
49
49
  const [showAllValues, setShowAllValues] = useState(false)
50
- const [diffRevisions, setDiffRevisions] = useState<{ rev1: number; rev2: number } | null>(null)
51
50
  const [rollbackRevision, setRollbackRevision] = useState<number | null>(null)
52
51
  const [showUninstallConfirm, setShowUninstallConfirm] = useState(false)
53
52
  const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false)
@@ -84,15 +83,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
84
83
  release.name,
85
84
  showAllValues,
86
85
  canViewSensitive,
87
- )
88
-
89
- // Fetch diff if comparing revisions
90
- const { data: diffData, isLoading: diffLoading } = useHelmManifestDiff(
91
- helmNamespace,
92
- release.name,
93
- diffRevisions?.rev1 || 0,
94
- diffRevisions?.rev2 || 0,
95
- canViewSensitive,
86
+ selectedRevision,
96
87
  )
97
88
 
98
89
  // Lazy check for upgrade availability
@@ -130,13 +121,24 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
130
121
  return () => document.removeEventListener('keydown', handleKeyDown)
131
122
  }, [onClose])
132
123
 
124
+ useEffect(() => {
125
+ const handleWindowResize = () => setViewportWidth(window.innerWidth)
126
+ handleWindowResize()
127
+ window.addEventListener('resize', handleWindowResize)
128
+ return () => window.removeEventListener('resize', handleWindowResize)
129
+ }, [])
130
+
131
+ const minDrawerWidth = Math.min(MIN_WIDTH, viewportWidth)
132
+ const maxDrawerWidth = viewportWidth < MIN_WIDTH ? viewportWidth : Math.max(MIN_WIDTH, viewportWidth * MAX_WIDTH_PERCENT)
133
+ const renderedDrawerWidth = Math.max(minDrawerWidth, Math.min(drawerWidth, maxDrawerWidth))
134
+
133
135
  // Resize handlers
134
136
  const handleResizeStart = useCallback((e: React.MouseEvent) => {
135
137
  e.preventDefault()
136
138
  setIsResizing(true)
137
139
  resizeStartX.current = e.clientX
138
- resizeStartWidth.current = drawerWidth
139
- }, [drawerWidth])
140
+ resizeStartWidth.current = renderedDrawerWidth
141
+ }, [renderedDrawerWidth])
140
142
 
141
143
  useEffect(() => {
142
144
  if (!isResizing) return
@@ -144,11 +146,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
144
146
  document.body.style.cursor = 'ew-resize'
145
147
  document.body.style.userSelect = 'none'
146
148
 
147
- const maxWidth = window.innerWidth * MAX_WIDTH_PERCENT
148
149
  const handleMouseMove = (e: MouseEvent) => {
149
150
  const deltaX = resizeStartX.current - e.clientX
150
151
  const newWidth = resizeStartWidth.current + deltaX
151
- setDrawerWidth(Math.max(MIN_WIDTH, Math.min(newWidth, maxWidth)))
152
+ const viewport = window.innerWidth
153
+ const minWidth = Math.min(MIN_WIDTH, viewport)
154
+ const maxWidth = viewport < MIN_WIDTH ? viewport : Math.max(MIN_WIDTH, viewport * MAX_WIDTH_PERCENT)
155
+ setDrawerWidth(Math.max(minWidth, Math.min(newWidth, maxWidth)))
152
156
  }
153
157
  const handleMouseUp = () => setIsResizing(false)
154
158
  document.addEventListener('mousemove', handleMouseMove)
@@ -174,10 +178,16 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
174
178
  startViewTransitionSafe(() => flushSync(() => setActiveTab(tab)))
175
179
  }, [])
176
180
 
177
- const handleCompareRevisions = (rev1: number, rev2: number) => {
178
- setDiffRevisions({ rev1, rev2 })
179
- switchTab('diff')
180
- }
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])
181
191
 
182
192
  const handleViewRevision = (revision: number) => {
183
193
  setSelectedRevision(revision)
@@ -308,11 +318,6 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
308
318
  { id: 'hooks', label: 'Hooks', icon: Anchor },
309
319
  ]
310
320
 
311
- // Add diff tab only when comparing
312
- if (diffRevisions) {
313
- tabs.push({ id: 'diff', label: 'Diff', icon: FileText })
314
- }
315
-
316
321
  return (
317
322
  <div
318
323
  className={clsx(
@@ -320,7 +325,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
320
325
  TRANSITION_DRAWER,
321
326
  isOpen ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
322
327
  )}
323
- style={{ width: drawerWidth, top: headerHeight, height: `calc(100vh - ${headerHeight}px - ${dockInset}px)` }}
328
+ style={{ width: renderedDrawerWidth, top: headerHeight, height: `calc(100vh - ${headerHeight}px - ${dockInset}px)` }}
324
329
  >
325
330
  {/* Resize handle */}
326
331
  <div
@@ -409,7 +414,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
409
414
  )}
410
415
  >
411
416
  <Link2 className="w-3 h-3" />
412
- source not tracked
417
+ upgrade source not tracked
413
418
  </button>
414
419
  </Tooltip>
415
420
  ) : (
@@ -515,7 +520,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
515
520
  <>
516
521
  <HelmOperationBanner
517
522
  operation={releaseDetail.lastOperation}
523
+ operationInsight={releaseDetail.operationInsight}
524
+ releaseNamespace={releaseDetail.namespace}
518
525
  managedByFluxHelmRelease={releaseDetail.managedByFluxHelmRelease}
526
+ hookDiagnostics={releaseDetail.hookDiagnostics}
527
+ canCompare={canViewSensitive}
528
+ onCompare={handleCompareRevisions}
529
+ onNavigateToResource={onNavigateToResource}
519
530
  />
520
531
  {activeTab === 'overview' && (
521
532
  <OverviewTab release={releaseDetail} onCopy={copyToClipboard} copied={copied} />
@@ -552,6 +563,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
552
563
  copied={copied === 'values'}
553
564
  namespace={helmNamespace}
554
565
  name={release.name}
566
+ revision={selectedRevision}
567
+ currentRevision={releaseDetail.revision}
555
568
  onApplySuccess={() => refetch()}
556
569
  />
557
570
  </RoleGatedPanel>
@@ -563,21 +576,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
563
576
  />
564
577
  )}
565
578
  {activeTab === 'hooks' && (
566
- <HooksTab hooks={releaseDetail.hooks || []} />
567
- )}
568
- {activeTab === 'diff' && diffRevisions && (
569
- <RoleGatedPanel min="member" feature="release manifest diffs">
570
- <ManifestDiffViewer
571
- diff={diffData?.diff || ''}
572
- isLoading={diffLoading}
573
- revision1={diffRevisions.rev1}
574
- revision2={diffRevisions.rev2}
575
- onClose={() => {
576
- setDiffRevisions(null)
577
- setActiveTab('history')
578
- }}
579
- />
580
- </RoleGatedPanel>
579
+ <HooksTab hooks={releaseDetail.hooks || []} hookDiagnostics={releaseDetail.hookDiagnostics || []} />
581
580
  )}
582
581
  </>
583
582
  )}
@@ -743,20 +742,54 @@ function helmOperationKey(operation: HelmOperation): string {
743
742
 
744
743
  function HelmOperationBanner({
745
744
  operation,
745
+ operationInsight,
746
+ releaseNamespace,
746
747
  managedByFluxHelmRelease,
748
+ hookDiagnostics,
749
+ canCompare,
750
+ onCompare,
751
+ onNavigateToResource,
747
752
  }: {
748
753
  operation?: HelmOperation
754
+ operationInsight?: HelmOperationInsight
755
+ releaseNamespace: string
749
756
  managedByFluxHelmRelease?: string
757
+ hookDiagnostics?: HookDiagnostic[]
758
+ canCompare?: boolean
759
+ onCompare?: (rev1: number, rev2: number) => void
760
+ onNavigateToResource?: NavigateToResource
750
761
  }) {
751
- if (!operation || !shouldShowOperationBanner(operation)) {
762
+ const primaryHookDiagnostic = hookDiagnostics?.[0]
763
+ if ((!operation || !shouldShowOperationBanner(operation)) && !primaryHookDiagnostic) {
752
764
  return null
753
765
  }
766
+ if (!operation || !shouldShowOperationBanner(operation)) {
767
+ const tone = hookDiagnosticTone(primaryHookDiagnostic!)
768
+ return (
769
+ <div className="m-4 mb-0 card-inner-lg">
770
+ <div className="flex items-start gap-3">
771
+ <AlertTriangle className={clsx('mt-0.5 h-5 w-5 shrink-0', SEVERITY_TEXT[tone])} />
772
+ <div className="min-w-0 flex-1">
773
+ <div className="flex flex-wrap items-center gap-2">
774
+ <span className="text-sm font-medium text-theme-text-primary">Helm hook needs attention</span>
775
+ <span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{primaryHookDiagnostic!.phase}</span>
776
+ </div>
777
+ <HookSignal diagnostic={primaryHookDiagnostic!} />
778
+ </div>
779
+ </div>
780
+ </div>
781
+ )
782
+ }
754
783
 
755
784
  const isFailure = operation.status === 'failed'
756
785
  const isPending = operation.status === 'stuck_pending'
757
786
  const tone: 'error' | 'warning' | 'info' = isFailure ? 'error' : operation.kind === 'rollback' ? 'info' : 'warning'
758
787
  const Icon = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' ? RotateCcw : isPending ? Clock : AlertTriangle
759
788
  const title = operationTitle(operation)
789
+ const statusLabel = operation.status.replace(/_/g, ' ')
790
+ const showStatusBadge = !(operation.status === 'failed' && title.toLowerCase().includes('failed'))
791
+ const rawMessage = operation.rawMessage?.trim()
792
+ const hasRawMessage = !!rawMessage && rawMessage !== operation.message.trim()
760
793
 
761
794
  return (
762
795
  <div className="m-4 mb-0 card-inner-lg">
@@ -765,20 +798,42 @@ function HelmOperationBanner({
765
798
  <div className="min-w-0 flex-1">
766
799
  <div className="flex flex-wrap items-center gap-2">
767
800
  <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>
801
+ {showStatusBadge && (
802
+ <span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{statusLabel}</span>
803
+ )}
769
804
  <OperationRevisionChips operation={operation} />
770
805
  </div>
771
806
  <p className="mt-1 text-sm text-theme-text-secondary">{operation.message}</p>
772
- {operation.failureDescription && (
773
- <p className="mt-1 text-xs text-theme-text-tertiary truncate">
774
- {operation.failureDescription}
775
- </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>
776
823
  )}
777
824
  {operation.kind === 'upgrade_rolled_back' && (
778
825
  <p className="mt-1 text-xs text-theme-text-tertiary">
779
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.
780
827
  </p>
781
828
  )}
829
+ <OperationInsightSignals
830
+ insight={operationInsight}
831
+ releaseNamespace={releaseNamespace}
832
+ canCompare={Boolean(canCompare)}
833
+ onCompare={onCompare}
834
+ onNavigateToResource={onNavigateToResource}
835
+ />
836
+ {primaryHookDiagnostic && <HookSignal diagnostic={primaryHookDiagnostic} />}
782
837
  {managedByFluxHelmRelease && (
783
838
  <p className="mt-1 text-xs text-theme-text-tertiary">
784
839
  This release is managed by Flux HelmRelease {managedByFluxHelmRelease}; direct Helm changes may be reconciled back.
@@ -790,6 +845,146 @@ function HelmOperationBanner({
790
845
  )
791
846
  }
792
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
+
967
+ function hookDiagnosticTone(diagnostic: HookDiagnostic): 'error' | 'warning' {
968
+ return diagnostic.phase.toLowerCase() === 'failed' ? 'error' : 'warning'
969
+ }
970
+
971
+ function HookSignal({ diagnostic }: { diagnostic: HookDiagnostic }) {
972
+ return (
973
+ <div className="mt-2 rounded-md bg-theme-base/50 p-2 text-xs">
974
+ <div className="font-medium text-theme-text-secondary">
975
+ Hook signal: {formatHookRef(diagnostic)} is {diagnostic.phase}
976
+ </div>
977
+ <div className="mt-1 text-theme-text-tertiary">{diagnostic.message}</div>
978
+ {diagnostic.evidence?.summary && (
979
+ <div className="mt-1 text-theme-text-secondary">{diagnostic.evidence.summary}</div>
980
+ )}
981
+ {diagnostic.evidenceUnavailableReason && (
982
+ <div className="mt-1 text-theme-text-tertiary">{diagnostic.evidenceUnavailableReason}</div>
983
+ )}
984
+ </div>
985
+ )
986
+ }
987
+
793
988
  function shouldShowOperationBanner(operation: HelmOperation): boolean {
794
989
  return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'failed' || operation.status === 'stuck_pending'
795
990
  }
@@ -849,12 +1044,15 @@ interface OverviewTabProps {
849
1044
  notes: string
850
1045
  readme?: string
851
1046
  dependencies?: ChartDependency[]
1047
+ lastOperation?: HelmOperation
852
1048
  }
853
1049
  onCopy: (text: string, key: string) => void
854
1050
  copied: string | null
855
1051
  }
856
1052
 
857
1053
  function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
1054
+ const showDescription = !!release.description && !isDuplicateOperationRawDescription(release.description, release.lastOperation)
1055
+
858
1056
  return (
859
1057
  <div className="p-4 space-y-4">
860
1058
  {/* Chart info */}
@@ -885,7 +1083,7 @@ function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
885
1083
  </div>
886
1084
 
887
1085
  {/* Description */}
888
- {release.description && (
1086
+ {showDescription && (
889
1087
  <div className="bg-theme-elevated/30 rounded-lg p-4">
890
1088
  <h3 className="text-sm font-medium text-theme-text-secondary mb-2">Description</h3>
891
1089
  <p className="text-sm text-theme-text-secondary">{release.description}</p>
@@ -969,12 +1167,23 @@ function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
969
1167
  )
970
1168
  }
971
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
+
972
1180
  // Hooks tab content
973
1181
  interface HooksTabProps {
974
1182
  hooks: HelmHook[]
1183
+ hookDiagnostics: HookDiagnostic[]
975
1184
  }
976
1185
 
977
- function HooksTab({ hooks }: HooksTabProps) {
1186
+ function HooksTab({ hooks, hookDiagnostics }: HooksTabProps) {
978
1187
  if (hooks.length === 0) {
979
1188
  return (
980
1189
  <div className="flex flex-col items-center justify-center h-48 text-theme-text-tertiary">
@@ -1006,23 +1215,31 @@ function HooksTab({ hooks }: HooksTabProps) {
1006
1215
  return SEVERITY_BADGE.neutral
1007
1216
  }
1008
1217
 
1218
+ const diagnostics = new Map<string, HookDiagnostic>()
1219
+ for (const diagnostic of hookDiagnostics) {
1220
+ diagnostics.set(hookDiagnosticKey(diagnostic.namespace, diagnostic.kind, diagnostic.name), diagnostic)
1221
+ diagnostics.set(hookDiagnosticKey(undefined, diagnostic.kind, diagnostic.name), diagnostic)
1222
+ }
1223
+
1009
1224
  return (
1010
1225
  <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">
1226
+ {hooks.map((hook) => {
1227
+ const diagnostic = diagnostics.get(hookDiagnosticKey(hook.namespace, hook.kind, hook.name))
1228
+ || diagnostics.get(hookDiagnosticKey(undefined, hook.kind, hook.name))
1229
+ return (
1230
+ <div key={`${hook.namespace || '_'}:${hook.kind}:${hook.name}`} className="bg-theme-elevated/30 rounded-lg p-4">
1231
+ <div className="flex items-start justify-between gap-3 mb-2">
1232
+ <div className="min-w-0">
1233
+ <div className="flex flex-wrap items-center gap-2">
1234
+ <span className="break-all text-theme-text-primary font-medium">{hook.name}</span>
1235
+ <span className="badge-sm shrink-0 bg-theme-hover/50 text-theme-text-secondary">
1021
1236
  {hook.kind}
1022
1237
  </span>
1023
1238
  </div>
1024
- <div className="flex items-center gap-2 mt-1 text-xs text-theme-text-tertiary">
1239
+ <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
1240
+ {hook.namespace && <span className="break-all">Namespace: {hook.namespace}</span>}
1025
1241
  <span>Weight: {hook.weight}</span>
1242
+ {hook.path && <span className="break-all">Path: {hook.path}</span>}
1026
1243
  </div>
1027
1244
  </div>
1028
1245
  {hook.status && (
@@ -1041,8 +1258,172 @@ function HooksTab({ hooks }: HooksTabProps) {
1041
1258
  </span>
1042
1259
  ))}
1043
1260
  </div>
1261
+ {(hook.startedAt || hook.completedAt || hook.deletePolicies?.length || hook.outputLogPolicies?.length) && (
1262
+ <div className="mt-3 grid grid-cols-1 gap-2 text-xs text-theme-text-tertiary sm:grid-cols-2">
1263
+ {hook.startedAt && (
1264
+ <div>
1265
+ <span className="text-theme-text-disabled">Started: </span>
1266
+ <span>{formatDate(hook.startedAt)}</span>
1267
+ </div>
1268
+ )}
1269
+ {hook.completedAt && (
1270
+ <div>
1271
+ <span className="text-theme-text-disabled">Completed: </span>
1272
+ <span>{formatDate(hook.completedAt)}</span>
1273
+ </div>
1274
+ )}
1275
+ {hook.deletePolicies && hook.deletePolicies.length > 0 && (
1276
+ <div className="sm:col-span-2">
1277
+ <span className="text-theme-text-disabled">Delete policies: </span>
1278
+ <span>{hook.deletePolicies.join(', ')}</span>
1279
+ </div>
1280
+ )}
1281
+ {hook.outputLogPolicies && hook.outputLogPolicies.length > 0 && (
1282
+ <div className="sm:col-span-2">
1283
+ <span className="text-theme-text-disabled">Output log policies: </span>
1284
+ <span>{hook.outputLogPolicies.join(', ')}</span>
1285
+ </div>
1286
+ )}
1287
+ </div>
1288
+ )}
1289
+ {diagnostic && <HookDiagnosticEvidence diagnostic={diagnostic} />}
1044
1290
  </div>
1045
- ))}
1291
+ )
1292
+ })}
1293
+ </div>
1294
+ )
1295
+ }
1296
+
1297
+ function hookDiagnosticKey(namespace: string | undefined, kind: string | undefined, name: string | undefined): string {
1298
+ return `${namespace || ''}/${(kind || '').toLowerCase()}/${name || ''}`
1299
+ }
1300
+
1301
+ function formatHookRef(hook: Pick<HookDiagnostic, 'namespace' | 'kind' | 'name'>): string {
1302
+ return `${hook.namespace ? `${hook.namespace}/` : ''}${hook.name} (${hook.kind})`
1303
+ }
1304
+
1305
+ function HookDiagnosticEvidence({ diagnostic }: { diagnostic: HookDiagnostic }) {
1306
+ const evidence = diagnostic.evidence
1307
+
1308
+ return (
1309
+ <div className="mt-3 border-t border-theme-border/60 pt-3 text-xs">
1310
+ <div className="flex flex-wrap items-center gap-2">
1311
+ <span className="font-medium text-theme-text-secondary">Diagnostic evidence</span>
1312
+ <span className={clsx('badge-sm', diagnostic.phase.toLowerCase() === 'failed' ? SEVERITY_BADGE.error : SEVERITY_BADGE.warning)}>
1313
+ {diagnostic.phase}
1314
+ </span>
1315
+ {evidence?.summary && <span className="text-theme-text-tertiary">{evidence.summary}</span>}
1316
+ </div>
1317
+ <div className="mt-1 text-theme-text-tertiary">{diagnostic.message}</div>
1318
+ {diagnostic.evidenceUnavailableReason && (
1319
+ <div className="mt-1 text-theme-text-tertiary">{diagnostic.evidenceUnavailableReason}</div>
1320
+ )}
1321
+ {evidence && (
1322
+ <div className="mt-3 space-y-3">
1323
+ {evidence.jobs && evidence.jobs.length > 0 && (
1324
+ <div className="space-y-1">
1325
+ {evidence.jobs.map((job) => (
1326
+ <div key={`${job.namespace || ''}/${job.name}`} className="grid grid-cols-[80px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1">
1327
+ <span className="text-theme-text-tertiary">Job</span>
1328
+ <span className="min-w-0 text-theme-text-primary">
1329
+ <span className="break-all font-medium">{job.namespace ? `${job.namespace}/` : ''}{job.name}</span>
1330
+ {job.status && <span className="ml-2 text-theme-text-secondary">{job.status}</span>}
1331
+ <span className="ml-2 text-theme-text-tertiary">
1332
+ active {job.active || 0} · succeeded {job.succeeded || 0} · failed {job.failed || 0}
1333
+ </span>
1334
+ </span>
1335
+ {job.conditions?.map((condition) => (
1336
+ <span key={condition} className="col-start-2 text-theme-text-tertiary">{condition}</span>
1337
+ ))}
1338
+ </div>
1339
+ ))}
1340
+ </div>
1341
+ )}
1342
+
1343
+ {evidence.pods && evidence.pods.length > 0 && (
1344
+ <div className="space-y-1">
1345
+ {evidence.pods.map((pod) => (
1346
+ <div key={`${pod.namespace || ''}/${pod.name}`} className="grid grid-cols-[80px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1">
1347
+ <span className="text-theme-text-tertiary">Pod</span>
1348
+ <span className="min-w-0 text-theme-text-primary">
1349
+ <span className="break-all font-medium">{pod.namespace ? `${pod.namespace}/` : ''}{pod.name}</span>
1350
+ {pod.phase && <span className="ml-2 text-theme-text-secondary">{pod.phase}</span>}
1351
+ {pod.ready && <span className="ml-2 text-theme-text-tertiary">{pod.ready} ready</span>}
1352
+ {Boolean(pod.restartCount) && <span className="ml-2 text-theme-text-tertiary">{pod.restartCount} restarts</span>}
1353
+ </span>
1354
+ {(pod.reason || pod.message) && (
1355
+ <span className="col-start-2 text-theme-text-tertiary">
1356
+ {pod.reason && <span className="font-medium">{pod.reason}: </span>}
1357
+ {pod.message}
1358
+ </span>
1359
+ )}
1360
+ </div>
1361
+ ))}
1362
+ </div>
1363
+ )}
1364
+
1365
+ {evidence.events && evidence.events.length > 0 && (
1366
+ <div className="space-y-1">
1367
+ {evidence.events.map((event, index) => (
1368
+ <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">
1369
+ <span className="text-theme-text-tertiary">Event</span>
1370
+ <span className="min-w-0 text-theme-text-primary">
1371
+ <span className={clsx('badge-sm mr-2', event.type === 'Warning' ? SEVERITY_BADGE.warning : SEVERITY_BADGE.neutral)}>
1372
+ {event.type || 'Event'}
1373
+ </span>
1374
+ <span className="font-medium">{event.reason || 'Unknown'}</span>
1375
+ <span className="ml-2 break-all text-theme-text-tertiary">{event.involvedKind}/{event.involvedName}</span>
1376
+ </span>
1377
+ {event.message && <span className="col-start-2 text-theme-text-tertiary">{event.message}</span>}
1378
+ </div>
1379
+ ))}
1380
+ </div>
1381
+ )}
1382
+
1383
+ {evidence.logs && evidence.logs.length > 0 && (
1384
+ <div className="space-y-2">
1385
+ {evidence.logs.map((log, index) => (
1386
+ <HookLogEvidenceBlock key={`${log.pod}/${log.container}/${log.previous ? 'previous' : 'current'}/${index}`} log={log} />
1387
+ ))}
1388
+ </div>
1389
+ )}
1390
+
1391
+ {evidence.errors && evidence.errors.length > 0 && (
1392
+ <div className="space-y-1 text-theme-text-tertiary">
1393
+ {evidence.errors.map((error) => (
1394
+ <div key={error}>Evidence read error: {error}</div>
1395
+ ))}
1396
+ </div>
1397
+ )}
1398
+ </div>
1399
+ )}
1400
+ </div>
1401
+ )
1402
+ }
1403
+
1404
+ function HookLogEvidenceBlock({ log }: { log: HookLogEvidence }) {
1405
+ const visibleLines = (log.lines || []).slice(0, 8)
1406
+
1407
+ return (
1408
+ <div className="rounded bg-theme-base/40 px-2 py-2">
1409
+ <div className="mb-1 flex flex-wrap items-center gap-2 text-theme-text-secondary">
1410
+ <span className="font-medium">Logs</span>
1411
+ <span className="break-all">{log.pod}/{log.container}</span>
1412
+ {log.previous && <span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">previous</span>}
1413
+ {log.fallback && <span className="text-theme-text-tertiary">fallback tail</span>}
1414
+ </div>
1415
+ {log.error ? (
1416
+ <div className="text-theme-text-tertiary">{log.error}</div>
1417
+ ) : (
1418
+ <>
1419
+ <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">
1420
+ {visibleLines.join('\n')}
1421
+ </pre>
1422
+ {log.lines && log.lines.length > visibleLines.length && (
1423
+ <div className="mt-1 text-theme-text-tertiary">+{log.lines.length - visibleLines.length} more lines in API response</div>
1424
+ )}
1425
+ </>
1426
+ )}
1046
1427
  </div>
1047
1428
  )
1048
1429
  }