@skyhook-io/radar-app 1.8.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/App.tsx CHANGED
@@ -1410,7 +1410,7 @@ function AppInner() {
1410
1410
  // the bar is full, and the view's primary home is Cloud's fleet
1411
1411
  // rail. The view still exists and is reachable via /applications
1412
1412
  // and the view-switching shortcuts. Same treatment as Cost below.
1413
- { view: 'traffic' as const, icon: Activity, label: 'Traffic' },
1413
+ { view: 'traffic' as const, icon: Activity, label: 'Live Traffic' },
1414
1414
  // Cost is intentionally hidden from the pill bar for now — the view still
1415
1415
  // exists and is reachable via /cost, the Home dashboard card, and the
1416
1416
  // command palette (⌘K). Remove this comment to restore it.
@@ -1793,7 +1793,7 @@ function AppInner() {
1793
1793
  <TopologySearch
1794
1794
  nodes={filteredTopology?.nodes ?? []}
1795
1795
  allNodes={topology?.nodes}
1796
- viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Traffic' : 'Resources'}
1796
+ viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Network Flow' : 'Resources'}
1797
1797
  onNodeSelect={handleNodeClick}
1798
1798
  onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
1799
1799
  // Stack below the namespace breadcrumb (shown only for a single
@@ -1815,6 +1815,7 @@ function AppInner() {
1815
1815
  showPolicyEffect={showPolicyEffect}
1816
1816
  onShowPolicyEffectChange={setShowPolicyEffect}
1817
1817
  showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
1818
+ onNavigateToTraffic={() => setMainView('traffic')}
1818
1819
  />
1819
1820
  </div>
1820
1821
  </>
package/src/api/client.ts CHANGED
@@ -2396,6 +2396,19 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
2396
2396
  })
2397
2397
  }
2398
2398
 
2399
+ // Available chart versions for a release (newest-first), for the upgrade dialog's
2400
+ // version picker. Empty when the source can't be resolved — the dialog then falls
2401
+ // back to the latest version from upgrade-info.
2402
+ export function useHelmReleaseVersions(namespace: string, name: string, enabled = true) {
2403
+ return useQuery<string[]>({
2404
+ queryKey: ['helm-release-versions', namespace, name],
2405
+ queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/versions`),
2406
+ enabled: Boolean(namespace && name && enabled),
2407
+ staleTime: 30000,
2408
+ retry: false,
2409
+ })
2410
+ }
2411
+
2399
2412
  // Batch check for upgrade availability (for list view)
2400
2413
  export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
2401
2414
  const params = helmNamespaceParams(namespaces)
@@ -2668,6 +2681,54 @@ export function useUpdateRepositorySilent() {
2668
2681
  })
2669
2682
  }
2670
2683
 
2684
+ // Registered OCI chart sources (the OCI analog of `helm repo add`). Used to
2685
+ // track upgrades for the user's own OCI-published charts.
2686
+ export function useHelmOCISources() {
2687
+ return useQuery<string[]>({
2688
+ queryKey: ['helm-oci-sources'],
2689
+ queryFn: () => fetchJSON('/helm/oci-sources'),
2690
+ })
2691
+ }
2692
+
2693
+ async function mutateOCISource(method: 'POST' | 'DELETE', source: string): Promise<string[]> {
2694
+ const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
2695
+ method,
2696
+ headers: { 'Content-Type': 'application/json' },
2697
+ body: JSON.stringify({ source }),
2698
+ })
2699
+ if (!response.ok) {
2700
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2701
+ throw new Error(error.error || `HTTP ${response.status}`)
2702
+ }
2703
+ return response.json()
2704
+ }
2705
+
2706
+ // Invalidate the upgrade-info queries so a newly-registered source is probed
2707
+ // immediately and "source not tracked" re-resolves.
2708
+ function invalidateHelmAfterSourceChange(queryClient: ReturnType<typeof useQueryClient>) {
2709
+ queryClient.invalidateQueries({ queryKey: ['helm-oci-sources'] })
2710
+ queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info'] })
2711
+ queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
2712
+ }
2713
+
2714
+ export function useAddOCISource() {
2715
+ const queryClient = useQueryClient()
2716
+ return useMutation({
2717
+ mutationFn: (source: string) => mutateOCISource('POST', source),
2718
+ meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
2719
+ onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2720
+ })
2721
+ }
2722
+
2723
+ export function useRemoveOCISource() {
2724
+ const queryClient = useQueryClient()
2725
+ return useMutation({
2726
+ mutationFn: (source: string) => mutateOCISource('DELETE', source),
2727
+ meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
2728
+ onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2729
+ })
2730
+ }
2731
+
2671
2732
  // Search charts across all repositories
2672
2733
  export function useSearchCharts(query: string, allVersions = false, enabled = true) {
2673
2734
  return useQuery<ChartSearchResult>({
@@ -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">
@@ -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
+ }
@@ -0,0 +1,141 @@
1
+ import { useState } from 'react'
2
+ import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
3
+ import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo } from '../../api/client'
6
+
7
+ interface TrackChartSourceDialogProps {
8
+ open: boolean
9
+ onClose: () => void
10
+ /** Chart name of the release this was opened from, for the example prompt. */
11
+ chartName?: string
12
+ }
13
+
14
+ // TrackChartSourceDialog lets the user register an OCI chart-source prefix — the
15
+ // OCI analog of `helm repo add`. Helm doesn't persist the ref a release was
16
+ // installed from, so for charts published to an OCI registry (and not managed by
17
+ // GitOps) Radar can only track upgrades once the user declares where they live.
18
+ // Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
19
+ export function TrackChartSourceDialog({ open, onClose, chartName }: TrackChartSourceDialogProps) {
20
+ const [value, setValue] = useState('')
21
+ const { data: sources } = useHelmOCISources()
22
+ const { data: clusterInfo } = useClusterInfo()
23
+ const addSource = useAddOCISource()
24
+ const removeSource = useRemoveOCISource()
25
+
26
+ // In-cluster Radar has no `helm registry login` store (the pod's HELM_CONFIG_HOME
27
+ // points at an empty /tmp), so private registries can't authenticate — only
28
+ // public charts can be tracked. Be honest about it rather than silently failing.
29
+ const inCluster = clusterInfo?.inCluster ?? false
30
+
31
+ const trimmed = value.trim()
32
+ const invalid = trimmed !== '' && !trimmed.startsWith('oci://')
33
+
34
+ const handleAdd = () => {
35
+ if (!trimmed || invalid) return
36
+ addSource.mutate(trimmed, { onSuccess: () => setValue('') })
37
+ }
38
+
39
+ return (
40
+ <DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
41
+ <div className="flex items-start gap-3 p-4 border-b border-theme-border">
42
+ <div className="flex items-center justify-center w-10 h-10 rounded-full shrink-0 bg-theme-hover">
43
+ <Link2 className="w-5 h-5 text-theme-text-secondary" />
44
+ </div>
45
+ <div className="flex-1 min-w-0">
46
+ <h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
47
+ <p className="text-sm text-theme-text-secondary mt-1">
48
+ Helm doesn&apos;t record where a chart was installed from. Register your OCI
49
+ registry prefix and Radar will check it for newer versions of your charts.
50
+ </p>
51
+ </div>
52
+ <button
53
+ onClick={onClose}
54
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
55
+ >
56
+ <X className="w-5 h-5" />
57
+ </button>
58
+ </div>
59
+
60
+ <div className="p-4 space-y-4">
61
+ <div>
62
+ <label className="block text-sm font-medium text-theme-text-secondary mb-2">
63
+ OCI registry prefix
64
+ </label>
65
+ <div className="flex gap-2">
66
+ <input
67
+ type="text"
68
+ value={value}
69
+ onChange={(e) => setValue(e.target.value)}
70
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
71
+ placeholder="oci://ghcr.io/myorg/charts"
72
+ aria-invalid={invalid ? true : undefined}
73
+ className={clsx(
74
+ 'flex-1 px-3 py-2 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
75
+ invalid ? 'border-red-500/60 focus:ring-red-500' : 'border-theme-border-light focus:ring-accent',
76
+ )}
77
+ />
78
+ <button
79
+ onClick={handleAdd}
80
+ disabled={!trimmed || invalid || addSource.isPending}
81
+ className="btn-brand px-3 py-2 text-sm inline-flex items-center gap-1 disabled:opacity-50 disabled:pointer-events-none"
82
+ >
83
+ <Plus className="w-4 h-4" />
84
+ Add
85
+ </button>
86
+ </div>
87
+ <p className="mt-1 text-xs text-theme-text-tertiary">
88
+ {invalid
89
+ ? 'Must be an oci:// reference.'
90
+ : chartName
91
+ ? `Radar will look for "${chartName}" under this prefix (and any others below).`
92
+ : 'Radar probes <prefix>/<chartName> for each untracked release.'}
93
+ </p>
94
+ </div>
95
+
96
+ {sources && sources.length > 0 && (
97
+ <div>
98
+ <p className="text-xs font-medium text-theme-text-tertiary uppercase tracking-wide mb-2">
99
+ Registered sources
100
+ </p>
101
+ <ul className="space-y-1">
102
+ {sources.map((src) => (
103
+ <li
104
+ key={src}
105
+ className="flex items-center justify-between gap-2 px-3 py-2 bg-theme-elevated rounded-lg"
106
+ >
107
+ <span className="text-sm text-theme-text-primary font-mono truncate">{src}</span>
108
+ <button
109
+ onClick={() => removeSource.mutate(src)}
110
+ disabled={removeSource.isPending}
111
+ className="p-1 text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50"
112
+ aria-label={`Remove ${src}`}
113
+ >
114
+ <Trash2 className="w-4 h-4" />
115
+ </button>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ </div>
120
+ )}
121
+
122
+ {inCluster ? (
123
+ <div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
124
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
125
+ <span>
126
+ Radar is running in-cluster, where it has no{' '}
127
+ <span className="font-mono">helm registry login</span> credentials — only{' '}
128
+ <strong>public</strong> charts can be tracked. Private-registry support for in-cluster
129
+ Radar isn&apos;t available yet.
130
+ </span>
131
+ </div>
132
+ ) : (
133
+ <p className="text-xs text-theme-text-tertiary">
134
+ Credentials are reused from your <span className="font-mono">helm registry login</span>.
135
+ Radar stores no registry secrets.
136
+ </p>
137
+ )}
138
+ </div>
139
+ </DialogPortal>
140
+ )
141
+ }
@@ -100,7 +100,7 @@ export function TrafficSummary({ data, onNavigate }: TrafficSummaryProps) {
100
100
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
101
101
  <div className="flex items-center gap-2">
102
102
  <Activity className="w-4 h-4 text-theme-text-tertiary" />
103
- <span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Traffic</span>
103
+ <span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Live Traffic</span>
104
104
  </div>
105
105
  {hasFlows && (
106
106
  <span className="text-[11px] text-theme-text-tertiary">
@@ -145,7 +145,7 @@ export function TrafficSummary({ data, onNavigate }: TrafficSummaryProps) {
145
145
  </div>
146
146
 
147
147
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-theme-text-secondary group-hover:text-theme-text-primary transition-colors">
148
- Open Traffic
148
+ Open Live Traffic
149
149
  <ArrowRight className="w-3.5 h-3.5 transition-transform group-hover:translate-x-0.5" />
150
150
  </div>
151
151
  </div>
@@ -126,7 +126,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
126
126
  },
127
127
  {
128
128
  name: 'get_changes',
129
- desc: 'Recent resource creates, updates, and deletes from the cluster timeline. Use to investigate what changed before an incident.',
129
+ desc: 'Recent resource creates, updates, and deletes from the Kubernetes timeline. Helm release history is separate; use list_helm_releases or get_helm_release include=history,operations for failed upgrades and rollbacks.',
130
130
  params: [
131
131
  { arg: 'namespace', desc: 'filter to a specific namespace' },
132
132
  { arg: 'kind', desc: 'filter to a resource kind (e.g. Deployment)' },
@@ -147,16 +147,16 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
147
147
  },
148
148
  {
149
149
  name: 'list_helm_releases',
150
- desc: 'All Helm releases in the cluster with status and resource health name, namespace, chart, version.',
150
+ desc: 'All Helm releases with status, resource health, storage namespace, Flux ownership, current lastOperation, and capped operation trails for failed upgrades, rollbacks, or stuck pending operations.',
151
151
  params: [{ arg: 'namespace', desc: 'filter to a specific namespace' }],
152
152
  },
153
153
  {
154
154
  name: 'get_helm_release',
155
- desc: 'Detailed Helm release info with owned resources and their status. Optionally include values, revision history, or a manifest diff between revisions.',
155
+ desc: 'Detailed Helm release info with owned resources, health, Flux ownership, and current lastOperation; include history and operations for the full revision trail.',
156
156
  params: [
157
- { arg: 'namespace', required: true, desc: 'release namespace' },
157
+ { arg: 'namespace', required: true, desc: 'Helm storage namespace; use storageNamespace from list_helm_releases when present' },
158
158
  { arg: 'name', required: true, desc: 'release name' },
159
- { arg: 'include', desc: 'values, history, diff' },
159
+ { arg: 'include', desc: 'values, history, operations, diff' },
160
160
  { arg: 'diff_revision_1', desc: 'first revision for diff' },
161
161
  { arg: 'diff_revision_2', desc: 'second revision for diff (defaults to current)' },
162
162
  ],
@@ -45,7 +45,7 @@ const NAV_ITEMS: NavItemDef[] = [
45
45
  { view: 'topology', icon: Network, label: 'Topology' },
46
46
  { view: 'applications', icon: Boxes, label: 'Applications' },
47
47
  { view: 'timeline', icon: Clock, label: 'Timeline' },
48
- { view: 'traffic', icon: Activity, label: 'Traffic' },
48
+ { view: 'traffic', icon: Activity, label: 'Live Traffic' },
49
49
  { view: 'helm', icon: Package, label: 'Helm' },
50
50
  { view: 'gitops', icon: GitBranch, label: 'GitOps' },
51
51
  { view: 'checks', icon: ShieldCheck, label: 'Checks' },
@@ -29,7 +29,7 @@ const VIEW_LABELS: Record<string, string> = {
29
29
  timeline: 'Timeline',
30
30
  helm: 'Helm',
31
31
  gitops: 'GitOps',
32
- traffic: 'Traffic',
32
+ traffic: 'Live Traffic',
33
33
  }
34
34
 
35
35
  type ShortcutEntry = { description: string; keys: string[] }
@@ -91,7 +91,7 @@ const VIEW_ENTRIES: { view: MainView; label: string; icon: React.ComponentType<{
91
91
  { view: 'timeline', label: 'Timeline', icon: Clock, shortcut: 'g l' },
92
92
  { view: 'helm', label: 'Helm', icon: Package, shortcut: 'g m' },
93
93
  { view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: 'g o' },
94
- { view: 'traffic', label: 'Traffic', icon: Activity, shortcut: 'g f' },
94
+ { view: 'traffic', label: 'Live Traffic', icon: Activity, shortcut: 'g f' },
95
95
  { view: 'checks', label: 'Checks', icon: ShieldCheck, shortcut: 'g u' },
96
96
  { view: 'cost', label: 'Cost', icon: DollarSign, shortcut: 'g c' },
97
97
  ]
@@ -62,6 +62,22 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
62
62
  // Track if SSE has started delivering connection_state events
63
63
  // Once SSE is active, it becomes the authoritative source for connection state
64
64
  const sseActiveRef = useRef(false)
65
+ // Track whether we've reached 'connected' at least once. Distinguishes the
66
+ // initial connect (bootstrap queries already fetched while 'connecting') from
67
+ // a reconnect after a drop (cache may be stale across the gap).
68
+ const hasConnectedRef = useRef(false)
69
+ // Whether the QueryClient already held data when this provider mounted. A host
70
+ // can share one client across cluster-scoped RadarApp mounts (see RadarApp's
71
+ // `queryClient` prop); that client may carry another cluster's data under
72
+ // identical keys, so a warm-at-mount cache must be fully refreshed on first
73
+ // connect. A cold cache (standalone, or a per-cluster remount) takes the cheap
74
+ // error-only path. Snapshot synchronously before this provider's own query
75
+ // registers — ConnectionProvider is the outermost provider, so a fresh client
76
+ // is genuinely empty here.
77
+ const cacheWarmAtMountRef = useRef<boolean | null>(null)
78
+ if (cacheWarmAtMountRef.current === null) {
79
+ cacheWarmAtMountRef.current = queryClient.getQueryCache().getAll().length > 0
80
+ }
65
81
 
66
82
  // Fetch initial connection status
67
83
  // Poll while connecting to get progress updates (SSE not established yet)
@@ -143,9 +159,20 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
143
159
  return status
144
160
  })
145
161
 
146
- // If we just connected, invalidate queries to fetch fresh data
147
162
  if (status.state === 'connected') {
148
- queryClient.invalidateQueries()
163
+ const firstConnect = !hasConnectedRef.current
164
+ hasConnectedRef.current = true
165
+ // A reconnect after a drop (cache stale across the gap), or a first connect
166
+ // onto a client that already carried data at mount (shared across clusters),
167
+ // refreshes the whole cache. A clean first connect only needs to recover the
168
+ // bootstrap queries that 503'd while the cluster was still 'connecting'
169
+ // (status === 'error'); the rest already fetched fresh during 'connecting',
170
+ // so re-fetching the whole cache there would double-load every endpoint.
171
+ if (!firstConnect || cacheWarmAtMountRef.current) {
172
+ queryClient.invalidateQueries()
173
+ } else {
174
+ queryClient.invalidateQueries({ predicate: (q) => q.state.status === 'error' })
175
+ }
149
176
  }
150
177
  }, [queryClient])
151
178