@skyhook-io/radar-app 1.8.0 → 1.8.2

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 +2 -2
  2. package/src/App.tsx +95 -16
  3. package/src/api/client.ts +64 -3
  4. package/src/components/DebugOverlay.tsx +1 -1
  5. package/src/components/applications/ApplicationsView.tsx +1 -1
  6. package/src/components/compare/CompareViewRoute.tsx +13 -5
  7. package/src/components/cost/CostTrendChart.tsx +3 -3
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/helm/ChartBrowser.tsx +1 -1
  10. package/src/components/helm/HelmReleaseDrawer.tsx +233 -19
  11. package/src/components/helm/HelmView.tsx +85 -16
  12. package/src/components/helm/InstallWizard.tsx +1 -1
  13. package/src/components/helm/RevisionHistory.tsx +46 -2
  14. package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
  15. package/src/components/home/ActivitySummary.tsx +4 -1
  16. package/src/components/home/TrafficSummary.tsx +2 -2
  17. package/src/components/home/mcpToolCatalog.ts +5 -5
  18. package/src/components/issues/IssuesPane.tsx +2 -2
  19. package/src/components/nav/PrimaryNavRail.tsx +1 -1
  20. package/src/components/portforward/PortForwardManager.tsx +70 -8
  21. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  22. package/src/components/resources/ResourcesView.tsx +2 -0
  23. package/src/components/settings/MyPermissionsDialog.tsx +64 -4
  24. package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
  25. package/src/components/traffic/TrafficGraph.tsx +29 -20
  26. package/src/components/traffic/TrafficView.tsx +3 -3
  27. package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
  28. package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
  29. package/src/components/ui/command-items.ts +1 -1
  30. package/src/components/workload/WorkloadView.tsx +17 -17
  31. package/src/context/ConnectionContext.tsx +29 -2
  32. package/src/main.tsx +1 -1
@@ -1,18 +1,18 @@
1
1
  import { useState, useCallback, useEffect, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
- import { FetchResult, useDockReservedHeight } from '@skyhook-io/k8s-ui'
3
+ import { FetchResult, useDockReservedHeight, compareVersions } from '@skyhook-io/k8s-ui'
4
4
  import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
5
5
  import { TRANSITION_DRAWER } from '../../utils/animation'
6
6
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
7
- import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch } from 'lucide-react'
7
+ import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch, AlertTriangle, RotateCcw, Clock } from 'lucide-react'
8
8
  import { useNavigate } from 'react-router-dom'
9
9
  import { clsx } from 'clsx'
10
- import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmUpgradeInfo, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
10
+ import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmUpgradeInfo, useHelmReleaseVersions, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
11
11
  import { useQueryClient } from '@tanstack/react-query'
12
12
  import { ConfirmDialog } from '../ui/ConfirmDialog'
13
13
  import { Tooltip } from '../ui/Tooltip'
14
14
  import { Markdown } from '../ui/Markdown'
15
- import type { SelectedHelmRelease, HelmHook, ChartDependency } from '../../types'
15
+ import type { SelectedHelmRelease, HelmHook, ChartDependency, HelmOperation } from '../../types'
16
16
  import type { NavigateToResource } from '../../utils/navigation'
17
17
  import { formatDate } from './helm-utils'
18
18
  import { getHelmStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
@@ -23,6 +23,7 @@ import { ManifestViewer } from './ManifestViewer'
23
23
  import { ValuesViewer } from './ValuesViewer'
24
24
  import { OwnedResources } from './OwnedResources'
25
25
  import { ManifestDiffViewer } from './ManifestDiffViewer'
26
+ import { TrackChartSourceDialog } from './TrackChartSourceDialog'
26
27
 
27
28
  interface HelmReleaseDrawerProps {
28
29
  release: SelectedHelmRelease
@@ -50,6 +51,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
50
51
  const [rollbackRevision, setRollbackRevision] = useState<number | null>(null)
51
52
  const [showUninstallConfirm, setShowUninstallConfirm] = useState(false)
52
53
  const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false)
54
+ const [showTrackSource, setShowTrackSource] = useState(false)
55
+ const [selectedVersion, setSelectedVersion] = useState<string | null>(null)
53
56
  const resizeStartX = useRef(0)
54
57
  const resizeStartWidth = useRef(DEFAULT_WIDTH)
55
58
  const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
@@ -99,6 +102,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
99
102
  )
100
103
  const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade check failed'
101
104
 
105
+ // Available versions for the upgrade dialog's picker — only fetched while the
106
+ // confirm dialog is open. Default the selection to latest when it opens.
107
+ const { data: availableVersions } = useHelmReleaseVersions(helmNamespace, release.name, showUpgradeConfirm)
108
+ const targetVersion = selectedVersion ?? upgradeInfo?.latestVersion ?? ''
109
+ // Semver compare, not list-position: the installed version may be older than
110
+ // the newest-N versions the picker shows, so it isn't always in the list.
111
+ const isDowngrade = Boolean(
112
+ targetVersion && upgradeInfo?.currentVersion &&
113
+ compareVersions(targetVersion, upgradeInfo.currentVersion) === -1
114
+ )
115
+
102
116
  // Mutations for actions
103
117
  const uninstallMutation = useHelmUninstall()
104
118
  const queryClient = useQueryClient()
@@ -234,7 +248,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
234
248
  }
235
249
 
236
250
  const handleUpgradeConfirm = async () => {
237
- if (!upgradeInfo?.latestVersion) return
251
+ if (!targetVersion) return
238
252
  setIsUpgrading(true)
239
253
  setUpgradeProgress([])
240
254
 
@@ -242,8 +256,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
242
256
  await upgradeWithProgress(
243
257
  helmNamespace,
244
258
  release.name,
245
- upgradeInfo.latestVersion,
246
- upgradeInfo.repositoryName,
259
+ targetVersion,
260
+ upgradeInfo?.repositoryName,
247
261
  (event) => {
248
262
  if (event.type === 'progress' && event.message) {
249
263
  setUpgradeProgress(prev => [...prev, {
@@ -256,7 +270,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
256
270
 
257
271
  setUpgradeProgress(prev => [...prev, {
258
272
  phase: 'complete',
259
- message: `Successfully upgraded to ${upgradeInfo.latestVersion}`,
273
+ message: `Successfully upgraded to ${targetVersion}`,
260
274
  }])
261
275
 
262
276
  // Invalidate queries
@@ -268,6 +282,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
268
282
  setTimeout(() => {
269
283
  setShowUpgradeConfirm(false)
270
284
  setUpgradeProgress([])
285
+ setSelectedVersion(null)
271
286
  refetch()
272
287
  switchTab('resources')
273
288
  }, 1500)
@@ -342,8 +357,18 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
342
357
  upgrade check failed
343
358
  </span>
344
359
  </Tooltip>
360
+ ) : upgradeInfo?.updateAvailable && releaseDetail?.managedByFluxHelmRelease ? (
361
+ // Route-only for GitOps-managed releases: a direct `helm upgrade`
362
+ // would be reverted at the next reconcile, so surface the available
363
+ // version as info and point at GitOps rather than offer the upgrade.
364
+ <Tooltip content={`${upgradeInfo.latestVersion} available — managed by Flux, upgrade via the GitOps view (a direct upgrade would be reverted at the next reconcile).`}>
365
+ <span className={clsx('badge', SEVERITY_BADGE.warning, 'opacity-90')}>
366
+ <ArrowUpCircle className="w-3 h-3" />
367
+ {upgradeInfo.latestVersion}
368
+ </span>
369
+ </Tooltip>
345
370
  ) : upgradeInfo?.updateAvailable ? (
346
- <Tooltip content={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : ''}` : helmActReason}>
371
+ <Tooltip content={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : upgradeInfo.sourceType === 'oci' ? ' (OCI)' : ''}` : helmActReason}>
347
372
  <button
348
373
  onClick={() => setShowUpgradeConfirm(true)}
349
374
  disabled={!canHelmWrite}
@@ -363,13 +388,39 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
363
388
  </span>
364
389
  </Tooltip>
365
390
  ) : upgradeInfo?.error ? (
366
- <Tooltip content={upgradeInfo.error}>
367
- <span
368
- className="badge bg-theme-hover/50 text-theme-text-secondary"
369
- >
370
- upstream unknown
371
- </span>
372
- </Tooltip>
391
+ releaseDetail?.managedByFluxHelmRelease ? (
392
+ // Managed by Flux — the "Managed by Flux" badge routes to GitOps,
393
+ // where the chart source lives. Don't push a Helm source here.
394
+ <Tooltip content="Chart source is managed by Flux — track upgrades from the GitOps view.">
395
+ <span className="badge bg-theme-hover/50 text-theme-text-secondary">
396
+ source via GitOps
397
+ </span>
398
+ </Tooltip>
399
+ ) : upgradeInfo.untracked ? (
400
+ // Helm doesn't record the install source. Offer to register one so
401
+ // Radar can track upgrades for the user's own (e.g. OCI) charts.
402
+ <Tooltip content={canHelmWrite ? "Radar can't tell where this chart was installed from. Register your chart source to track upgrades." : upgradeInfo.error}>
403
+ <button
404
+ onClick={() => canHelmWrite && setShowTrackSource(true)}
405
+ disabled={!canHelmWrite}
406
+ className={clsx(
407
+ 'badge transition-colors disabled:pointer-events-none bg-theme-hover/50 text-theme-text-secondary',
408
+ canHelmWrite ? 'hover:bg-theme-hover cursor-pointer' : 'opacity-50 cursor-not-allowed'
409
+ )}
410
+ >
411
+ <Link2 className="w-3 h-3" />
412
+ source not tracked
413
+ </button>
414
+ </Tooltip>
415
+ ) : (
416
+ // Repo-side error (stale/broken index, classic ambiguity) — not an
417
+ // OCI tracking issue, so surface it without steering to registration.
418
+ <Tooltip content={upgradeInfo.error}>
419
+ <span className="badge bg-theme-hover/50 text-theme-text-secondary">
420
+ upgrade source unresolved
421
+ </span>
422
+ </Tooltip>
423
+ )
373
424
  ) : null}
374
425
  </div>
375
426
  <div className="flex items-center gap-1">
@@ -462,6 +513,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
462
513
  <FetchResult loading={isLoading} error={releaseError} notFoundMessage="Release not found" className="h-32" />
463
514
  ) : (
464
515
  <>
516
+ <HelmOperationBanner
517
+ operation={releaseDetail.lastOperation}
518
+ managedByFluxHelmRelease={releaseDetail.managedByFluxHelmRelease}
519
+ />
465
520
  {activeTab === 'overview' && (
466
521
  <OverviewTab release={releaseDetail} onCopy={copyToClipboard} copied={copied} />
467
522
  )}
@@ -469,6 +524,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
469
524
  <RevisionHistory
470
525
  history={releaseDetail.history}
471
526
  currentRevision={releaseDetail.revision}
527
+ operations={mergeHelmOperations(releaseDetail.operations, releaseDetail.lastOperation)}
472
528
  onViewRevision={handleViewRevision}
473
529
  onCompare={handleCompareRevisions}
474
530
  onRollback={canHelmWrite ? handleRollbackRequest : undefined}
@@ -572,6 +628,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
572
628
  onClose={() => {
573
629
  setShowUpgradeConfirm(false)
574
630
  setUpgradeProgress([])
631
+ setSelectedVersion(null)
575
632
  if (isUpgrading) {
576
633
  // Upgrade continues server-side — switch to resources tab to monitor
577
634
  setIsUpgrading(false)
@@ -580,18 +637,56 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
580
637
  }}
581
638
  onConfirm={handleUpgradeConfirm}
582
639
  title="Upgrade Release"
583
- message={`Upgrade "${release.name}" to version ${upgradeInfo?.latestVersion}?`}
640
+ message={`Upgrade "${release.name}" to version ${targetVersion}?`}
584
641
  details={upgradeProgress.length === 0
585
- ? `This will upgrade the chart from version ${upgradeInfo?.currentVersion} to ${upgradeInfo?.latestVersion}. Your existing values will be preserved. The upgrade will be applied to your cluster immediately.`
642
+ ? `The chart will move from version ${upgradeInfo?.currentVersion} to ${targetVersion}. Your existing values will be preserved. The change is applied to your cluster immediately.`
586
643
  : undefined
587
644
  }
588
- confirmLabel="Upgrade"
645
+ confirmLabel={isDowngrade ? 'Downgrade' : 'Upgrade'}
589
646
  variant="warning"
590
647
  isLoading={isUpgrading}
591
648
  isClosable
592
649
  >
650
+ {upgradeProgress.length === 0 && availableVersions && availableVersions.length > 1 && (
651
+ <div className="mb-1">
652
+ <label htmlFor="upgrade-version" className="block text-sm font-medium text-theme-text-secondary mb-1.5">
653
+ Target version
654
+ </label>
655
+ <select
656
+ id="upgrade-version"
657
+ value={targetVersion}
658
+ onChange={(e) => setSelectedVersion(e.target.value)}
659
+ disabled={isUpgrading}
660
+ className="w-full px-3 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
661
+ >
662
+ {availableVersions.map((v) => (
663
+ <option key={v} value={v}>
664
+ {v}
665
+ {v === upgradeInfo?.latestVersion ? ' (latest)' : ''}
666
+ {v === upgradeInfo?.currentVersion ? ' (current)' : ''}
667
+ </option>
668
+ ))}
669
+ </select>
670
+ {isDowngrade && (
671
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
672
+ This is a downgrade from {upgradeInfo?.currentVersion}.
673
+ </p>
674
+ )}
675
+ {availableVersions.length >= 50 && (
676
+ <p className="mt-1 text-xs text-theme-text-tertiary">
677
+ Showing the 50 newest versions. Type to filter.
678
+ </p>
679
+ )}
680
+ </div>
681
+ )}
593
682
  {upgradeProgress.length > 0 && <ProgressLog entries={upgradeProgress} />}
594
683
  </ConfirmDialog>
684
+
685
+ <TrackChartSourceDialog
686
+ open={showTrackSource}
687
+ onClose={() => setShowTrackSource(false)}
688
+ chartName={releaseDetail?.chart}
689
+ />
595
690
  </div>
596
691
  )
597
692
  }
@@ -623,6 +718,125 @@ function ProgressLog({ entries }: { entries: { phase: string; message: string }[
623
718
  )
624
719
  }
625
720
 
721
+ function mergeHelmOperations(operations: HelmOperation[] | undefined, lastOperation: HelmOperation | undefined): HelmOperation[] {
722
+ const merged: HelmOperation[] = []
723
+ const seen = new Set<string>()
724
+ for (const op of [...(operations || []), ...(lastOperation ? [lastOperation] : [])]) {
725
+ const key = helmOperationKey(op)
726
+ if (seen.has(key)) continue
727
+ seen.add(key)
728
+ merged.push(op)
729
+ }
730
+ return merged
731
+ }
732
+
733
+ function helmOperationKey(operation: HelmOperation): string {
734
+ return [
735
+ operation.kind,
736
+ operation.status,
737
+ operation.revision || 0,
738
+ operation.failedRevision || 0,
739
+ operation.rollbackRevision || 0,
740
+ operation.targetRevision || 0,
741
+ ].join(':')
742
+ }
743
+
744
+ function HelmOperationBanner({
745
+ operation,
746
+ managedByFluxHelmRelease,
747
+ }: {
748
+ operation?: HelmOperation
749
+ managedByFluxHelmRelease?: string
750
+ }) {
751
+ if (!operation || !shouldShowOperationBanner(operation)) {
752
+ return null
753
+ }
754
+
755
+ const isFailure = operation.status === 'failed'
756
+ const isPending = operation.status === 'stuck_pending'
757
+ const tone: 'error' | 'warning' | 'info' = isFailure ? 'error' : operation.kind === 'rollback' ? 'info' : 'warning'
758
+ const Icon = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' ? RotateCcw : isPending ? Clock : AlertTriangle
759
+ const title = operationTitle(operation)
760
+
761
+ return (
762
+ <div className="m-4 mb-0 card-inner-lg">
763
+ <div className="flex items-start gap-3">
764
+ <Icon className={clsx('mt-0.5 h-5 w-5 shrink-0', SEVERITY_TEXT[tone])} />
765
+ <div className="min-w-0 flex-1">
766
+ <div className="flex flex-wrap items-center gap-2">
767
+ <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>
769
+ <OperationRevisionChips operation={operation} />
770
+ </div>
771
+ <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>
776
+ )}
777
+ {operation.kind === 'upgrade_rolled_back' && (
778
+ <p className="mt-1 text-xs text-theme-text-tertiary">
779
+ 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
+ </p>
781
+ )}
782
+ {managedByFluxHelmRelease && (
783
+ <p className="mt-1 text-xs text-theme-text-tertiary">
784
+ This release is managed by Flux HelmRelease {managedByFluxHelmRelease}; direct Helm changes may be reconciled back.
785
+ </p>
786
+ )}
787
+ </div>
788
+ </div>
789
+ </div>
790
+ )
791
+ }
792
+
793
+ function shouldShowOperationBanner(operation: HelmOperation): boolean {
794
+ return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'failed' || operation.status === 'stuck_pending'
795
+ }
796
+
797
+ function operationTitle(operation: HelmOperation): string {
798
+ switch (operation.kind) {
799
+ case 'upgrade_rolled_back':
800
+ return 'Helm rolled back after failed upgrade'
801
+ case 'rollback':
802
+ return 'Helm rollback applied'
803
+ case 'pending':
804
+ return 'Helm operation may be stuck'
805
+ case 'upgrade_failed':
806
+ return 'Helm upgrade failed'
807
+ case 'release_failed':
808
+ return 'Helm release failed'
809
+ default:
810
+ return 'Helm operation'
811
+ }
812
+ }
813
+
814
+ function OperationRevisionChips({ operation }: { operation: HelmOperation }) {
815
+ const chips: Array<{ key: string; label: string; className: string }> = []
816
+ if (operation.failedRevision) {
817
+ chips.push({ key: 'failed', label: `failed rev ${operation.failedRevision}`, className: SEVERITY_BADGE.error })
818
+ }
819
+ if (operation.rollbackRevision) {
820
+ chips.push({ key: 'rollback', label: `rollback rev ${operation.rollbackRevision}`, className: SEVERITY_BADGE.warning })
821
+ }
822
+ if (operation.targetRevision) {
823
+ chips.push({ key: 'target', label: `target rev ${operation.targetRevision}`, className: SEVERITY_BADGE.info })
824
+ }
825
+ if (!operation.failedRevision && !operation.rollbackRevision && operation.revision) {
826
+ chips.push({ key: 'revision', label: `rev ${operation.revision}`, className: SEVERITY_BADGE.neutral })
827
+ }
828
+ if (chips.length === 0) {
829
+ return null
830
+ }
831
+ return (
832
+ <>
833
+ {chips.map((chip) => (
834
+ <span key={chip.key} className={clsx('badge-sm', chip.className)}>{chip.label}</span>
835
+ ))}
836
+ </>
837
+ )
838
+ }
839
+
626
840
  // Overview tab content
627
841
  interface OverviewTabProps {
628
842
  release: {
@@ -1,12 +1,12 @@
1
1
  import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'react'
2
2
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
3
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
4
- import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight } from 'lucide-react'
4
+ import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight, RotateCcw, Clock } from 'lucide-react'
5
5
  import { PaneLoader, PageHeader } from '@skyhook-io/k8s-ui'
6
6
  import { clsx } from 'clsx'
7
7
  import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
8
- import type { HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
9
- import { getStatusColor, formatAge, truncate, isHelmReleaseActionable } from './helm-utils'
8
+ import type { HelmOperation, HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
9
+ import { getStatusColor, formatAge, isHelmReleaseActionable } from './helm-utils'
10
10
  import { SEVERITY_BADGE } from '../../utils/badge-colors'
11
11
  import { Tooltip } from '../ui/Tooltip'
12
12
  import { ChartBrowser } from './ChartBrowser'
@@ -291,22 +291,22 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
291
291
  <table className="w-full table-fixed">
292
292
  <thead className="bg-theme-surface sticky top-0 z-10">
293
293
  <tr>
294
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
294
+ <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[28%]">
295
295
  Name
296
296
  </th>
297
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-32">
297
+ <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[18%]">
298
298
  Namespace
299
299
  </th>
300
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-48">
300
+ <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[22%]">
301
301
  Chart
302
302
  </th>
303
303
  <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24 hidden xl:table-cell">
304
304
  App Version
305
305
  </th>
306
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-28">
306
+ <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-40">
307
307
  Status
308
308
  </th>
309
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-20">
309
+ <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-16">
310
310
  Rev
311
311
  </th>
312
312
  <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24">
@@ -411,8 +411,74 @@ function getActionableTooltip(issue: string | undefined, summary: string | undef
411
411
  )
412
412
  }
413
413
 
414
+ function getListOperation(release: HelmRelease): HelmOperation | undefined {
415
+ if (release.lastOperation) {
416
+ const releaseStatus = release.status.toLowerCase()
417
+ if (release.lastOperation.status === 'failed' && releaseStatus === 'failed') {
418
+ return undefined
419
+ }
420
+ if (isListOperation(release.lastOperation)) {
421
+ return release.lastOperation
422
+ }
423
+ }
424
+ return release.operations?.find(isListOperation)
425
+ }
426
+
427
+ function isListOperation(operation: HelmOperation): boolean {
428
+ return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'stuck_pending'
429
+ }
430
+
431
+ function HelmOperationChip({ operation }: { operation: HelmOperation }) {
432
+ const isPending = operation.status === 'stuck_pending'
433
+ const isRollback = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback'
434
+ const Icon = isRollback ? RotateCcw : Clock
435
+ const tone: keyof typeof SEVERITY_BADGE = operation.kind === 'rollback' ? 'info' : isPending ? 'warning' : 'alert'
436
+ const label = operation.kind === 'upgrade_rolled_back'
437
+ ? 'rolled back'
438
+ : operation.kind === 'rollback'
439
+ ? 'rollback'
440
+ : 'stuck'
441
+
442
+ return (
443
+ <Tooltip content={
444
+ <div className="max-w-xs">
445
+ <div className="font-medium text-theme-text-primary">{operationSummary(operation)}</div>
446
+ <div className="mt-1 text-[10px] text-theme-text-secondary">{operation.message}</div>
447
+ {operation.failureDescription && (
448
+ <div className="mt-1.5 border-t border-theme-border pt-1.5 text-[10px] text-theme-text-tertiary">
449
+ {operation.failureDescription}
450
+ </div>
451
+ )}
452
+ </div>
453
+ }>
454
+ <span className={clsx('badge-sm shrink-0', SEVERITY_BADGE[tone])} aria-label={operationSummary(operation)}>
455
+ <Icon className="h-3 w-3" />
456
+ <span className="sr-only">{label}</span>
457
+ <span className="hidden 2xl:inline" aria-hidden="true">{label}</span>
458
+ </span>
459
+ </Tooltip>
460
+ )
461
+ }
462
+
463
+ function operationSummary(operation: HelmOperation): string {
464
+ switch (operation.kind) {
465
+ case 'upgrade_rolled_back':
466
+ return 'Helm rolled back after failed upgrade'
467
+ case 'rollback':
468
+ return 'Helm rollback applied'
469
+ case 'pending':
470
+ return 'Helm operation may be stuck'
471
+ case 'upgrade_failed':
472
+ return 'Helm upgrade failed'
473
+ default:
474
+ return 'Helm release failed'
475
+ }
476
+ }
477
+
414
478
  const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
415
479
  function ReleaseRow({ release, upgradeInfo, isSelected, isHighlighted, onClick, onMouseEnter }, ref) {
480
+ const listOperation = getListOperation(release)
481
+
416
482
  // Health badge styling
417
483
  const getHealthBadge = () => {
418
484
  if (!release.resourceHealth || release.resourceHealth === 'unknown') return null
@@ -454,10 +520,11 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
454
520
  )}
455
521
  >
456
522
  <td className="px-4 py-3">
457
- <div className="flex items-center gap-2">
523
+ <div className="flex min-w-0 items-center gap-2">
458
524
  <Package className="w-4 h-4 text-theme-text-tertiary shrink-0" />
459
- <span className="text-sm text-theme-text-primary font-medium truncate">{release.name}</span>
525
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{release.name}</span>
460
526
  {getHealthBadge()}
527
+ {listOperation && <HelmOperationChip operation={listOperation} />}
461
528
  {release.managedByFluxHelmRelease && (
462
529
  <Tooltip content={`Installed by Flux helm-controller via HelmRelease ${release.managedByFluxHelmRelease}. Changes here will be reverted at the next reconcile — manage via the GitOps tab.`}>
463
530
  <span className="badge-sm shrink-0 border border-theme-border bg-theme-elevated text-theme-text-secondary">
@@ -475,13 +542,15 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
475
542
  )}
476
543
  </div>
477
544
  </td>
478
- <td className="px-4 py-3 w-32">
479
- <span className="text-sm text-theme-text-secondary">{release.namespace}</span>
545
+ <td className="px-4 py-3 w-[18%]">
546
+ <Tooltip content={release.namespace}>
547
+ <span className="block truncate text-sm text-theme-text-secondary">{release.namespace}</span>
548
+ </Tooltip>
480
549
  </td>
481
- <td className="px-4 py-3 w-48">
550
+ <td className="px-4 py-3 w-[22%]">
482
551
  <Tooltip content={`${release.chart}-${release.chartVersion}`}>
483
552
  <span className="text-sm text-theme-text-secondary truncate block">
484
- {truncate(`${release.chart}-${release.chartVersion}`, 35)}
553
+ {release.chart}-{release.chartVersion}
485
554
  </span>
486
555
  </Tooltip>
487
556
  </td>
@@ -494,7 +563,7 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
494
563
  </Tooltip>
495
564
  )}
496
565
  </td>
497
- <td className="px-4 py-3 w-28">
566
+ <td className="px-4 py-3 w-40">
498
567
  {isHelmReleaseActionable(release.status) ? (
499
568
  <Tooltip content="Click row to view rollback / history / logs and recover">
500
569
  <span
@@ -515,7 +584,7 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
515
584
  </span>
516
585
  )}
517
586
  </td>
518
- <td className="px-4 py-3 w-20">
587
+ <td className="px-4 py-3 w-16">
519
588
  <span className="text-sm text-theme-text-secondary">{release.revision}</span>
520
589
  </td>
521
590
  <td className="px-4 py-3 w-24">
@@ -191,7 +191,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
191
191
  } finally {
192
192
  setIsInstalling(false)
193
193
  }
194
- }, [releaseName, namespace, chartName, version, repo, valuesYaml, createNamespace, onSuccess, isLocal, artifactHubDetail, queryClient])
194
+ }, [releaseName, namespace, chartName, version, repo, repoUrl, valuesYaml, createNamespace, onSuccess, isLocal, artifactHubDetail, queryClient])
195
195
 
196
196
  // Validate release name + namespace before letting the user
197
197
  // advance. Without this, a name like "Invalid Name With Spaces!"
@@ -1,7 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { History, Eye, GitCompare, Check, RotateCcw } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import type { HelmRevision } from '../../types'
4
+ import type { HelmOperation, HelmRevision } from '../../types'
5
5
  import { getStatusColor, formatDate, formatAge } from './helm-utils'
6
6
  import { SEVERITY_BADGE } from '../../utils/badge-colors'
7
7
  import { Tooltip } from '../ui/Tooltip'
@@ -9,12 +9,13 @@ import { Tooltip } from '../ui/Tooltip'
9
9
  interface RevisionHistoryProps {
10
10
  history: HelmRevision[]
11
11
  currentRevision: number
12
+ operations?: HelmOperation[]
12
13
  onViewRevision: (revision: number) => void
13
14
  onCompare: (rev1: number, rev2: number) => void
14
15
  onRollback?: (revision: number) => void
15
16
  }
16
17
 
17
- export function RevisionHistory({ history, currentRevision, onViewRevision, onCompare, onRollback }: RevisionHistoryProps) {
18
+ export function RevisionHistory({ history, currentRevision, operations = [], onViewRevision, onCompare, onRollback }: RevisionHistoryProps) {
18
19
  const [selectedForCompare, setSelectedForCompare] = useState<number | null>(null)
19
20
 
20
21
  const handleCompareClick = (revision: number) => {
@@ -63,6 +64,7 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
63
64
  {history.map((revision, index) => {
64
65
  const isCurrent = revision.revision === currentRevision
65
66
  const isSelectedForCompare = selectedForCompare === revision.revision
67
+ const annotations = operationAnnotationsForRevision(operations, revision.revision)
66
68
 
67
69
  return (
68
70
  <div
@@ -106,6 +108,11 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
106
108
  Current
107
109
  </span>
108
110
  )}
111
+ {annotations.map((annotation) => (
112
+ <span key={annotation.label} className={clsx('badge-sm', annotation.className)}>
113
+ {annotation.label}
114
+ </span>
115
+ ))}
109
116
  </div>
110
117
 
111
118
  <div className="flex items-center gap-4 mt-1 text-xs text-theme-text-tertiary">
@@ -166,3 +173,40 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
166
173
  </div>
167
174
  )
168
175
  }
176
+
177
+ function operationAnnotationsForRevision(operations: HelmOperation[], revision: number): Array<{ label: string; className: string }> {
178
+ const annotations: Array<{ label: string; className: string }> = []
179
+ const seen = new Set<string>()
180
+ const add = (label: string, className: string) => {
181
+ if (seen.has(label)) return
182
+ seen.add(label)
183
+ annotations.push({ label, className })
184
+ }
185
+
186
+ for (const op of operations) {
187
+ if (op.failedRevision === revision) {
188
+ add('Failed upgrade', SEVERITY_BADGE.error)
189
+ }
190
+ if (op.rollbackRevision === revision) {
191
+ add('Rollback revision', SEVERITY_BADGE.warning)
192
+ }
193
+ if (op.revision === revision) {
194
+ switch (op.kind) {
195
+ case 'upgrade_failed':
196
+ add('Failed upgrade', SEVERITY_BADGE.error)
197
+ break
198
+ case 'release_failed':
199
+ add('Failed', SEVERITY_BADGE.error)
200
+ break
201
+ case 'rollback':
202
+ add('Rollback', SEVERITY_BADGE.warning)
203
+ break
204
+ case 'pending':
205
+ add('Pending', SEVERITY_BADGE.warning)
206
+ break
207
+ }
208
+ }
209
+ }
210
+
211
+ return annotations
212
+ }