@skyhook-io/radar-app 1.8.1 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +167 -56
  3. package/src/RadarApp.tsx +18 -1
  4. package/src/api/client.ts +173 -6
  5. package/src/components/NamespaceSwitcher.tsx +52 -30
  6. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  7. package/src/components/gitops/GitOpsView.tsx +1 -10
  8. package/src/components/helm/HelmReleaseDrawer.tsx +802 -44
  9. package/src/components/helm/HelmView.tsx +85 -16
  10. package/src/components/helm/ManifestDiffViewer.tsx +15 -4
  11. package/src/components/helm/OwnedResources.tsx +14 -50
  12. package/src/components/helm/RevisionHistory.tsx +50 -2
  13. package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
  14. package/src/components/helm/ValuesViewer.tsx +41 -11
  15. package/src/components/home/TrafficSummary.tsx +2 -2
  16. package/src/components/home/mcpToolCatalog.ts +10 -10
  17. package/src/components/nav/PrimaryNavRail.tsx +1 -1
  18. package/src/components/portforward/PortForwardButton.tsx +69 -25
  19. package/src/components/portforward/PortForwardManager.tsx +18 -4
  20. package/src/components/resources/ResourcesView.tsx +42 -1
  21. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  22. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  23. package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
  24. package/src/components/ui/UpdateNotification.tsx +5 -10
  25. package/src/components/ui/command-items.ts +1 -1
  26. package/src/components/workload/WorkloadView.tsx +52 -7
  27. package/src/context/ConnectionContext.tsx +29 -2
  28. package/src/contexts/CapabilitiesContext.tsx +8 -0
  29. package/src/hooks/useDocumentTitle.ts +25 -0
  30. package/src/main.tsx +5 -3
  31. package/src/utils/auditBadges.ts +53 -0
  32. package/src/utils/navigation.ts +5 -3
@@ -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">
@@ -8,19 +8,21 @@ interface ManifestDiffViewerProps {
8
8
  revision1: number
9
9
  revision2: number
10
10
  onClose: () => void
11
+ title?: string
12
+ emptyLabel?: string
11
13
  }
12
14
 
13
- export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onClose }: ManifestDiffViewerProps) {
15
+ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onClose, title, emptyLabel }: ManifestDiffViewerProps) {
14
16
  if (isLoading) {
15
17
  return <PaneLoader label="Computing diff…" className="h-32" />
16
18
  }
17
19
 
18
- if (!diff) {
20
+ if (!hasDiffBodyChange(diff)) {
19
21
  return (
20
22
  <div className="p-4">
21
23
  <div className="flex flex-col items-center justify-center h-32 text-theme-text-tertiary gap-2">
22
24
  <GitCompare className="w-8 h-8 text-theme-text-disabled" />
23
- <span>No differences found</span>
25
+ <span>{emptyLabel || 'No differences found'}</span>
24
26
  </div>
25
27
  </div>
26
28
  )
@@ -32,7 +34,7 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
32
34
  <div className="flex items-center gap-2">
33
35
  <GitCompare className="w-4 h-4 text-theme-text-secondary" />
34
36
  <span className="text-sm font-medium text-theme-text-secondary">
35
- Comparing Revision {revision1} → {revision2}
37
+ {title || `Comparing Revision ${revision1} → ${revision2}`}
36
38
  </span>
37
39
  </div>
38
40
  <button
@@ -67,6 +69,15 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
67
69
  )
68
70
  }
69
71
 
72
+ function hasDiffBodyChange(diff: string): boolean {
73
+ return diff.split('\n').some((line) => {
74
+ if (!line || line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')) {
75
+ return false
76
+ }
77
+ return line.startsWith('+') || line.startsWith('-')
78
+ })
79
+ }
80
+
70
81
  function DiffLine({ line }: { line: string }) {
71
82
  const isAddition = line.startsWith('+') && !line.startsWith('+++')
72
83
  const isRemoval = line.startsWith('-') && !line.startsWith('---')
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback } from 'react'
2
- import { Link2, ExternalLink, AlertCircle, Terminal, FileText, Plug, X, Loader2 } from 'lucide-react'
2
+ import { Link2, ExternalLink, AlertCircle, Terminal, FileText, X, Loader2 } from 'lucide-react'
3
3
  import { getResourceIcon } from '../../utils/resource-icons'
4
4
  import { clsx } from 'clsx'
5
5
  import type { HelmOwnedResource } from '../../types'
@@ -8,10 +8,10 @@ import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
8
8
  import { getResourceStatusColor, SEVERITY_BADGE } from '../../utils/badge-colors'
9
9
  import { useQueryClient } from '@tanstack/react-query'
10
10
  import { useOpenTerminal, useOpenLogs } from '../dock'
11
- import { useStartPortForward } from '../portforward/PortForwardManager'
11
+ import { PortForwardInlineButton } from '../portforward/PortForwardButton'
12
12
  import { useAvailablePorts } from '../../api/client'
13
13
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
14
- import { useNamespacedCapabilities } from '../../contexts/CapabilitiesContext'
14
+ import { useNamespacedCapabilities, useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
15
15
  import { pluralize } from '@skyhook-io/k8s-ui'
16
16
  import { Tooltip } from '../ui/Tooltip'
17
17
 
@@ -312,11 +312,14 @@ function PodQuickActions({ namespace, podName, isRunning }: PodQuickActionsProps
312
312
  const queryClient = useQueryClient()
313
313
  const openTerminal = useOpenTerminal()
314
314
  const openLogs = useOpenLogs()
315
- const startPortForward = useStartPortForward()
316
315
  const { data: portsData, isLoading: portsLoading } = useAvailablePorts('pod', namespace, podName)
317
316
 
318
317
  // Capabilities (namespace-scoped: re-checks RBAC if globally denied)
319
318
  const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
319
+ // Live forward (local + RBAC) or the kubectl copy-command (in-cluster/Cloud);
320
+ // PortForwardInlineButton picks which by deployment mode.
321
+ const isLocal = useIsLocalDeployment()
322
+ const showPortForward = canPortForward || !isLocal
320
323
 
321
324
  const [isLoadingAction, setIsLoadingAction] = useState(false)
322
325
 
@@ -374,14 +377,6 @@ function PodQuickActions({ namespace, podName, isRunning }: PodQuickActionsProps
374
377
  }
375
378
  }, [namespace, podName, openLogs, fetchPodData])
376
379
 
377
- const handlePortForward = useCallback((port: number) => {
378
- startPortForward.mutate({
379
- namespace,
380
- podName,
381
- podPort: port,
382
- })
383
- }, [namespace, podName, startPortForward])
384
-
385
380
  const ports = portsData?.ports || []
386
381
 
387
382
  return (
@@ -414,21 +409,9 @@ function PodQuickActions({ namespace, podName, isRunning }: PodQuickActionsProps
414
409
  </Tooltip>
415
410
  )}
416
411
 
417
- {/* Port Forward */}
418
- {canPortForward && !portsLoading && ports.length > 0 && (
419
- <Tooltip content={`Port forward :${ports[0].port}`}>
420
- <button
421
- onClick={(e) => { e.stopPropagation(); handlePortForward(ports[0].port) }}
422
- disabled={startPortForward.isPending}
423
- className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none"
424
- >
425
- {startPortForward.isPending ? (
426
- <Loader2 className="w-3.5 h-3.5 animate-spin" />
427
- ) : (
428
- <Plug className="w-3.5 h-3.5" />
429
- )}
430
- </button>
431
- </Tooltip>
412
+ {/* Port Forward (live locally; kubectl copy-command in-cluster/Cloud) */}
413
+ {showPortForward && !portsLoading && ports.length > 0 && (
414
+ <PortForwardInlineButton namespace={namespace} podName={podName} port={ports[0].port} />
432
415
  )}
433
416
  </div>
434
417
  )
@@ -441,37 +424,18 @@ interface ServiceQuickActionsProps {
441
424
  }
442
425
 
443
426
  function ServiceQuickActions({ namespace, serviceName }: ServiceQuickActionsProps) {
444
- const startPortForward = useStartPortForward()
445
427
  const { data: portsData, isLoading: portsLoading } = useAvailablePorts('service', namespace, serviceName)
446
428
  const { canPortForward } = useNamespacedCapabilities(namespace)
447
-
448
- const handlePortForward = useCallback((port: number) => {
449
- startPortForward.mutate({
450
- namespace,
451
- serviceName,
452
- podPort: port,
453
- })
454
- }, [namespace, serviceName, startPortForward])
429
+ const isLocal = useIsLocalDeployment()
430
+ const showPortForward = canPortForward || !isLocal
455
431
 
456
432
  const ports = portsData?.ports || []
457
433
 
458
- if (!canPortForward || portsLoading || ports.length === 0) return null
434
+ if (!showPortForward || portsLoading || ports.length === 0) return null
459
435
 
460
436
  return (
461
437
  <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
462
- <Tooltip content={`Port forward :${ports[0].port}`}>
463
- <button
464
- onClick={(e) => { e.stopPropagation(); handlePortForward(ports[0].port) }}
465
- disabled={startPortForward.isPending}
466
- className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none"
467
- >
468
- {startPortForward.isPending ? (
469
- <Loader2 className="w-3.5 h-3.5 animate-spin" />
470
- ) : (
471
- <Plug className="w-3.5 h-3.5" />
472
- )}
473
- </button>
474
- </Tooltip>
438
+ <PortForwardInlineButton namespace={namespace} serviceName={serviceName} port={ports[0].port} />
475
439
  </div>
476
440
  )
477
441
  }
@@ -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, revision.status)
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,44 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
166
173
  </div>
167
174
  )
168
175
  }
176
+
177
+ function operationAnnotationsForRevision(operations: HelmOperation[], revision: number, revisionStatus: string): 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
+ const addFailure = (label: string) => {
186
+ if (revisionStatus.toLowerCase() === 'failed') return
187
+ add(label, SEVERITY_BADGE.error)
188
+ }
189
+
190
+ for (const op of operations) {
191
+ if (op.failedRevision === revision) {
192
+ addFailure('Failed upgrade')
193
+ }
194
+ if (op.rollbackRevision === revision) {
195
+ add('Rollback revision', SEVERITY_BADGE.warning)
196
+ }
197
+ if (op.revision === revision) {
198
+ switch (op.kind) {
199
+ case 'upgrade_failed':
200
+ addFailure('Failed upgrade')
201
+ break
202
+ case 'release_failed':
203
+ addFailure('Failed')
204
+ break
205
+ case 'rollback':
206
+ add('Rollback', SEVERITY_BADGE.warning)
207
+ break
208
+ case 'pending':
209
+ add('Pending', SEVERITY_BADGE.warning)
210
+ break
211
+ }
212
+ }
213
+ }
214
+
215
+ return annotations
216
+ }
@@ -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
+ }