@skyhook-io/radar-app 1.8.2 → 1.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +412 -146
  3. package/src/RadarApp.tsx +21 -1
  4. package/src/api/client.ts +144 -12
  5. package/src/components/ConnectionErrorView.tsx +1 -1
  6. package/src/components/ContextSwitcher.tsx +5 -1
  7. package/src/components/NamespaceSwitcher.tsx +21 -278
  8. package/src/components/applications/ApplicationsView.tsx +13 -1
  9. package/src/components/audit/AuditView.tsx +11 -2
  10. package/src/components/cost/CostView.tsx +12 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  12. package/src/components/gitops/GitOpsView.tsx +23 -17
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +18 -7
  17. package/src/components/helm/OwnedResources.tsx +14 -50
  18. package/src/components/helm/RevisionHistory.tsx +9 -5
  19. package/src/components/helm/ValuesViewer.tsx +41 -11
  20. package/src/components/home/ClusterHealthCard.tsx +6 -1
  21. package/src/components/home/HomeView.tsx +12 -1
  22. package/src/components/home/mcpToolCatalog.ts +8 -8
  23. package/src/components/issues/IssuesPane.tsx +29 -18
  24. package/src/components/portforward/PortForwardButton.tsx +69 -25
  25. package/src/components/portforward/PortForwardManager.tsx +18 -4
  26. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  27. package/src/components/resources/ResourcesView.tsx +45 -1
  28. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  29. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  30. package/src/components/timeline/TimelineView.tsx +26 -2
  31. package/src/components/traffic/TrafficView.tsx +17 -10
  32. package/src/components/ui/Markdown.tsx +2 -2
  33. package/src/components/ui/Omnibar.tsx +1 -1
  34. package/src/components/ui/UpdateNotification.tsx +5 -10
  35. package/src/components/workload/WorkloadView.tsx +57 -8
  36. package/src/contexts/CapabilitiesContext.tsx +8 -0
  37. package/src/filter/FilterLocationBridge.tsx +30 -0
  38. package/src/hooks/useDocumentTitle.ts +25 -0
  39. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  40. package/src/index.ts +15 -0
  41. package/src/main.tsx +5 -3
  42. package/src/utils/auditBadges.ts +53 -0
  43. package/src/utils/navigation.ts +5 -3
@@ -1,13 +1,13 @@
1
1
  import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'react'
2
- import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
2
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
4
- import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight, RotateCcw, Clock } from 'lucide-react'
5
- import { PaneLoader, PageHeader } from '@skyhook-io/k8s-ui'
3
+ import { Package, Search, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight, RotateCcw, Clock } from 'lucide-react'
4
+ import { PaneLoader, PageHeader, SortableTh, FreshnessControl, type SortDir } from '@skyhook-io/k8s-ui'
5
+ import { useConnection } from '../../context/ConnectionContext'
6
6
  import { clsx } from 'clsx'
7
7
  import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
8
8
  import type { HelmOperation, HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
9
9
  import { getStatusColor, formatAge, isHelmReleaseActionable } from './helm-utils'
10
- import { SEVERITY_BADGE } from '../../utils/badge-colors'
10
+ import { SEVERITY_BADGE, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
11
11
  import { Tooltip } from '../ui/Tooltip'
12
12
  import { ChartBrowser } from './ChartBrowser'
13
13
  import { InstallWizard } from './InstallWizard'
@@ -25,7 +25,7 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
25
25
  const [searchTerm, setSearchTerm] = useState('')
26
26
  const [selectedChart, setSelectedChart] = useState<{ repo: string; chart: string; version: string; source: ChartSource } | null>(null)
27
27
 
28
- const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespaces)
28
+ const { data: releases, isLoading, error: releasesError, dataUpdatedAt: releasesUpdatedAt, isFetching: releasesFetching, refetch: refetchReleases } = useHelmReleases(namespaces)
29
29
  const isForbidden = isForbiddenError(releasesError)
30
30
  const releasesErrorMessage = releasesError instanceof Error ? releasesError.message : 'Failed to load Helm releases'
31
31
 
@@ -36,24 +36,38 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
36
36
  )
37
37
  const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
38
38
 
39
- const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(async () => {
40
- await Promise.all([refetchReleases(), refetchUpgradeInfo()])
41
- })
42
-
43
- const isFullyLoaded = !isLoading && !upgradeLoading
39
+ const { connection } = useConnection()
40
+ const refetchAll = () => Promise.all([refetchReleases(), refetchUpgradeInfo()])
44
41
 
45
42
  // Filter releases by search term
43
+ // Resources-table cycle: asc → desc → off (null restores the server's order).
44
+ const [sort, setSort] = useState<{ key: HelmSortKey; dir: SortDir } | null>(null)
45
+ const onSort = useCallback(
46
+ (key: HelmSortKey) =>
47
+ setSort((prev) => {
48
+ if (!prev || prev.key !== key) return { key, dir: 'asc' }
49
+ if (prev.dir === 'asc') return { key, dir: 'desc' }
50
+ return null
51
+ }),
52
+ [],
53
+ )
54
+
46
55
  const filteredReleases = useMemo(() => {
47
56
  if (!releases) return []
48
- if (!searchTerm) return releases
49
- const term = searchTerm.toLowerCase()
50
- return releases.filter(
51
- (r) =>
52
- r.name.toLowerCase().includes(term) ||
53
- r.namespace.toLowerCase().includes(term) ||
54
- r.chart.toLowerCase().includes(term)
55
- )
56
- }, [releases, searchTerm])
57
+ const term = searchTerm.trim().toLowerCase()
58
+ const filtered = term
59
+ ? releases.filter(
60
+ (r) =>
61
+ r.name.toLowerCase().includes(term) ||
62
+ r.namespace.toLowerCase().includes(term) ||
63
+ r.chart.toLowerCase().includes(term)
64
+ )
65
+ : releases
66
+
67
+ if (!sort) return filtered
68
+ const factor = sort.dir === 'asc' ? 1 : -1
69
+ return [...filtered].sort((a, b) => compareReleases(a, b, sort.key) * factor)
70
+ }, [releases, searchTerm, sort])
57
71
 
58
72
  // Keyboard navigation state
59
73
  const searchInputRef = useRef<HTMLInputElement>(null)
@@ -62,8 +76,9 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
62
76
  const filteredReleasesCountRef = useRef(0)
63
77
  filteredReleasesCountRef.current = filteredReleases.length
64
78
 
65
- // Reset highlight when search changes
66
- useEffect(() => { setHighlightedIndex(-1) }, [searchTerm])
79
+ // Reset highlight when the visible order changes (search or sort) — otherwise
80
+ // the index would point at whatever release shifted into that row.
81
+ useEffect(() => { setHighlightedIndex(-1) }, [searchTerm, sort])
67
82
 
68
83
  // Scroll highlighted row into view
69
84
  useEffect(() => {
@@ -173,6 +188,17 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
173
188
  icon={Package}
174
189
  title="Helm"
175
190
  description="Installed Helm releases and the chart catalog for this cluster."
191
+ actions={
192
+ activeTab === 'releases' ? (
193
+ <FreshnessControl
194
+ mode="snapshot"
195
+ dataUpdatedAt={releasesUpdatedAt}
196
+ isFetching={releasesFetching || upgradeLoading}
197
+ onRefresh={refetchAll}
198
+ connectionState={connection.state}
199
+ />
200
+ ) : undefined
201
+ }
176
202
  />
177
203
  </div>
178
204
  {/* Tab bar */}
@@ -212,9 +238,6 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
212
238
  <>
213
239
  {/* Releases Toolbar */}
214
240
  <div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 shrink-0">
215
- {!isFullyLoaded && (
216
- <RefreshCw className="w-3.5 h-3.5 animate-spin text-theme-text-tertiary shrink-0" />
217
- )}
218
241
  <div className="flex-1 relative">
219
242
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
220
243
  <input
@@ -226,15 +249,6 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
226
249
  className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
227
250
  />
228
251
  </div>
229
- <Tooltip content="Refresh">
230
- <button
231
- onClick={handleRefresh}
232
- disabled={isRefreshAnimating}
233
- className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50 disabled:pointer-events-none"
234
- >
235
- <RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
236
- </button>
237
- </Tooltip>
238
252
  </div>
239
253
 
240
254
  {/* Releases Table */}
@@ -289,29 +303,15 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
289
303
  </div>
290
304
  ) : (
291
305
  <table className="w-full table-fixed">
292
- <thead className="bg-theme-surface sticky top-0 z-10">
306
+ <thead className="bg-theme-base sticky top-0 z-10">
293
307
  <tr>
294
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[28%]">
295
- Name
296
- </th>
297
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[18%]">
298
- Namespace
299
- </th>
300
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[22%]">
301
- Chart
302
- </th>
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
- App Version
305
- </th>
306
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-40">
307
- Status
308
- </th>
309
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-16">
310
- Rev
311
- </th>
312
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24">
313
- Updated
314
- </th>
308
+ <SortableTh label="Name" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[28%]" />
309
+ <SortableTh label="Namespace" sortKey="namespace" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[18%]" />
310
+ <SortableTh label="Chart" sortKey="chart" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[22%]" />
311
+ <SortableTh label="App Version" sortKey="appVersion" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-24 hidden xl:table-cell" />
312
+ <SortableTh label="Status" sortKey="status" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-40" />
313
+ <SortableTh label="Rev" sortKey="revision" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-16" />
314
+ <SortableTh label="Updated" sortKey="updated" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-24" />
315
315
  </tr>
316
316
  </thead>
317
317
  <tbody className="table-divide-subtle">
@@ -360,6 +360,23 @@ function releaseIdentityKey(release: Pick<HelmRelease, 'namespace' | 'name' | 's
360
360
  return `${release.storageNamespace || release.namespace}/${release.name}`
361
361
  }
362
362
 
363
+ type HelmSortKey = 'name' | 'namespace' | 'chart' | 'appVersion' | 'status' | 'revision' | 'updated'
364
+
365
+ function compareReleases(a: HelmRelease, b: HelmRelease, key: HelmSortKey): number {
366
+ let cmp: number
367
+ switch (key) {
368
+ case 'revision':
369
+ cmp = a.revision - b.revision
370
+ break
371
+ case 'updated':
372
+ cmp = (Date.parse(a.updated) || 0) - (Date.parse(b.updated) || 0)
373
+ break
374
+ default:
375
+ cmp = String(a[key] ?? '').localeCompare(String(b[key] ?? ''))
376
+ }
377
+ return cmp || a.name.localeCompare(b.name)
378
+ }
379
+
363
380
  interface ReleaseRowProps {
364
381
  release: HelmRelease
365
382
  upgradeInfo?: UpgradeInfo
@@ -392,7 +409,7 @@ function getActionableTooltip(issue: string | undefined, summary: string | undef
392
409
  <div className="max-w-xs">
393
410
  <div className={clsx(
394
411
  'font-medium',
395
- health === 'unhealthy' ? 'text-red-400' : 'text-yellow-400'
412
+ health === 'unhealthy' ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning
396
413
  )}>
397
414
  {summary || issue || health}
398
415
  </div>
@@ -483,10 +500,10 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
483
500
  const getHealthBadge = () => {
484
501
  if (!release.resourceHealth || release.resourceHealth === 'unknown') return null
485
502
 
486
- const healthStyles: Record<string, { bg: string; text: string; dot: string }> = {
487
- healthy: { bg: 'bg-green-500/10', text: 'text-green-400', dot: 'bg-green-500' },
488
- degraded: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', dot: 'bg-yellow-500' },
489
- unhealthy: { bg: 'bg-red-500/10', text: 'text-red-400', dot: 'bg-red-500' },
503
+ const healthStyles: Record<string, { badge: string; dot: string }> = {
504
+ healthy: { badge: SEVERITY_BADGE.success, dot: SEVERITY_DOT.success },
505
+ degraded: { badge: SEVERITY_BADGE.warning, dot: SEVERITY_DOT.warning },
506
+ unhealthy: { badge: SEVERITY_BADGE.error, dot: SEVERITY_DOT.error },
490
507
  }
491
508
 
492
509
  const style = healthStyles[release.resourceHealth] || healthStyles.healthy
@@ -495,8 +512,8 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
495
512
  return (
496
513
  <Tooltip content={tooltipContent}>
497
514
  <span className={clsx(
498
- 'flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded shrink-0',
499
- style.bg, style.text
515
+ 'badge-sm shrink-0',
516
+ style.badge
500
517
  )}>
501
518
  <span className={clsx('w-1.5 h-1.5 rounded-full', style.dot)} />
502
519
  {release.healthIssue || (release.resourceHealth !== 'healthy' ? release.healthSummary : null)}
@@ -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,7 +69,16 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
67
69
  )
68
70
  }
69
71
 
70
- function DiffLine({ line }: { line: string }) {
72
+ export 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
+
81
+ export function DiffLine({ line }: { line: string }) {
71
82
  const isAddition = line.startsWith('+') && !line.startsWith('+++')
72
83
  const isRemoval = line.startsWith('-') && !line.startsWith('---')
73
84
  const isHeader = line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')
@@ -76,8 +87,8 @@ function DiffLine({ line }: { line: string }) {
76
87
  <div
77
88
  className={clsx(
78
89
  'whitespace-pre',
79
- isAddition && 'bg-green-500/10 text-green-400',
80
- isRemoval && 'bg-red-500/10 text-red-400',
90
+ isAddition && 'bg-green-500/10 text-green-700 dark:text-green-400',
91
+ isRemoval && 'bg-red-500/10 text-red-700 dark:text-red-400',
81
92
  isHeader && 'text-theme-text-tertiary font-bold',
82
93
  !isAddition && !isRemoval && !isHeader && 'text-theme-text-secondary'
83
94
  )}
@@ -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
  }
@@ -64,7 +64,7 @@ export function RevisionHistory({ history, currentRevision, operations = [], onV
64
64
  {history.map((revision, index) => {
65
65
  const isCurrent = revision.revision === currentRevision
66
66
  const isSelectedForCompare = selectedForCompare === revision.revision
67
- const annotations = operationAnnotationsForRevision(operations, revision.revision)
67
+ const annotations = operationAnnotationsForRevision(operations, revision.revision, revision.status)
68
68
 
69
69
  return (
70
70
  <div
@@ -174,7 +174,7 @@ export function RevisionHistory({ history, currentRevision, operations = [], onV
174
174
  )
175
175
  }
176
176
 
177
- function operationAnnotationsForRevision(operations: HelmOperation[], revision: number): Array<{ label: string; className: string }> {
177
+ function operationAnnotationsForRevision(operations: HelmOperation[], revision: number, revisionStatus: string): Array<{ label: string; className: string }> {
178
178
  const annotations: Array<{ label: string; className: string }> = []
179
179
  const seen = new Set<string>()
180
180
  const add = (label: string, className: string) => {
@@ -182,10 +182,14 @@ function operationAnnotationsForRevision(operations: HelmOperation[], revision:
182
182
  seen.add(label)
183
183
  annotations.push({ label, className })
184
184
  }
185
+ const addFailure = (label: string) => {
186
+ if (revisionStatus.toLowerCase() === 'failed') return
187
+ add(label, SEVERITY_BADGE.error)
188
+ }
185
189
 
186
190
  for (const op of operations) {
187
191
  if (op.failedRevision === revision) {
188
- add('Failed upgrade', SEVERITY_BADGE.error)
192
+ addFailure('Failed upgrade')
189
193
  }
190
194
  if (op.rollbackRevision === revision) {
191
195
  add('Rollback revision', SEVERITY_BADGE.warning)
@@ -193,10 +197,10 @@ function operationAnnotationsForRevision(operations: HelmOperation[], revision:
193
197
  if (op.revision === revision) {
194
198
  switch (op.kind) {
195
199
  case 'upgrade_failed':
196
- add('Failed upgrade', SEVERITY_BADGE.error)
200
+ addFailure('Failed upgrade')
197
201
  break
198
202
  case 'release_failed':
199
- add('Failed', SEVERITY_BADGE.error)
203
+ addFailure('Failed')
200
204
  break
201
205
  case 'rollback':
202
206
  add('Rollback', SEVERITY_BADGE.warning)
@@ -1,4 +1,4 @@
1
- import { useState, useCallback } from 'react'
1
+ import { useState, useCallback, useEffect } from 'react'
2
2
  import { Copy, Check, Settings, Pencil, X, Eye, Play, Loader2 } from 'lucide-react'
3
3
  import { PaneLoader } from '@skyhook-io/k8s-ui'
4
4
  import { clsx } from 'clsx'
@@ -21,6 +21,8 @@ interface ValuesViewerProps {
21
21
  // Required for editing
22
22
  namespace?: string
23
23
  name?: string
24
+ revision?: number
25
+ currentRevision?: number
24
26
  onApplySuccess?: () => void
25
27
  }
26
28
 
@@ -33,6 +35,8 @@ export function ValuesViewer({
33
35
  copied,
34
36
  namespace,
35
37
  name,
38
+ revision,
39
+ currentRevision,
36
40
  onApplySuccess,
37
41
  }: ValuesViewerProps) {
38
42
  const [isEditing, setIsEditing] = useState(false)
@@ -44,8 +48,9 @@ export function ValuesViewer({
44
48
  const previewMutation = useHelmPreviewValues()
45
49
  const applyMutation = useHelmApplyValues()
46
50
  const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
51
+ const isHistoricalRevision = typeof revision === 'number' && typeof currentRevision === 'number' && revision !== currentRevision
47
52
 
48
- const canEdit = Boolean(namespace && name) && canHelmWrite
53
+ const canEdit = Boolean(namespace && name) && canHelmWrite && !isHistoricalRevision
49
54
 
50
55
  const displayValues = showAllValues && values?.computed ? values.computed : values?.userSupplied
51
56
  const isEmpty = !displayValues || Object.keys(displayValues).length === 0
@@ -72,6 +77,12 @@ export function ValuesViewer({
72
77
  setShowPreview(false)
73
78
  }, [])
74
79
 
80
+ useEffect(() => {
81
+ if (isHistoricalRevision && isEditing) {
82
+ handleCancelEdit()
83
+ }
84
+ }, [isHistoricalRevision, isEditing, handleCancelEdit])
85
+
75
86
  // Parse YAML and validate
76
87
  const parseYaml = useCallback((yamlStr: string): Record<string, unknown> | null => {
77
88
  try {
@@ -86,7 +97,7 @@ export function ValuesViewer({
86
97
 
87
98
  // Preview changes
88
99
  const handlePreview = useCallback(async () => {
89
- if (!namespace || !name) return
100
+ if (!namespace || !name || isHistoricalRevision) return
90
101
  const parsed = parseYaml(editedYaml)
91
102
  if (!parsed) return
92
103
 
@@ -101,11 +112,11 @@ export function ValuesViewer({
101
112
  } catch {
102
113
  // Error is handled by mutation
103
114
  }
104
- }, [namespace, name, editedYaml, parseYaml, previewMutation])
115
+ }, [namespace, name, isHistoricalRevision, editedYaml, parseYaml, previewMutation])
105
116
 
106
117
  // Apply changes
107
118
  const handleApply = useCallback(async () => {
108
- if (!namespace || !name) return
119
+ if (!namespace || !name || isHistoricalRevision) return
109
120
  const parsed = parseYaml(editedYaml)
110
121
  if (!parsed) return
111
122
 
@@ -120,11 +131,11 @@ export function ValuesViewer({
120
131
  } catch {
121
132
  // Error is handled by mutation
122
133
  }
123
- }, [namespace, name, editedYaml, parseYaml, applyMutation, handleCancelEdit, onApplySuccess])
134
+ }, [namespace, name, isHistoricalRevision, editedYaml, parseYaml, applyMutation, handleCancelEdit, onApplySuccess])
124
135
 
125
136
  // Apply from preview modal
126
137
  const handleApplyFromPreview = useCallback(async () => {
127
- if (!previewData || !namespace || !name) return
138
+ if (!previewData || !namespace || !name || isHistoricalRevision) return
128
139
  try {
129
140
  await applyMutation.mutateAsync({
130
141
  namespace,
@@ -137,7 +148,7 @@ export function ValuesViewer({
137
148
  } catch {
138
149
  // Error is handled by mutation
139
150
  }
140
- }, [previewData, namespace, name, applyMutation, handleCancelEdit, onApplySuccess])
151
+ }, [previewData, namespace, name, isHistoricalRevision, applyMutation, handleCancelEdit, onApplySuccess])
141
152
 
142
153
  if (isLoading) {
143
154
  return <PaneLoader label="Loading values…" className="h-32" />
@@ -147,7 +158,12 @@ export function ValuesViewer({
147
158
  return (
148
159
  <div className="p-4">
149
160
  <div className="flex items-center justify-between mb-3">
150
- <span className="text-sm font-medium text-theme-text-secondary">Values</span>
161
+ <div className="flex items-center gap-2">
162
+ <span className="text-sm font-medium text-theme-text-secondary">Values</span>
163
+ {isHistoricalRevision && (
164
+ <span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">revision {revision}</span>
165
+ )}
166
+ </div>
151
167
  <div className="flex items-center gap-2">
152
168
  <ToggleButton showAll={showAllValues} onToggle={onToggleAllValues} disabled={isEditing} />
153
169
  {canEdit && (
@@ -161,6 +177,11 @@ export function ValuesViewer({
161
177
  )}
162
178
  </div>
163
179
  </div>
180
+ {isHistoricalRevision && (
181
+ <div className="mb-3 rounded border border-theme-border bg-theme-elevated/40 px-3 py-2 text-xs text-theme-text-secondary">
182
+ Viewing historical values. Switch back to the latest revision before editing or applying changes.
183
+ </div>
184
+ )}
164
185
  <div className="flex flex-col items-center justify-center h-32 text-theme-text-tertiary gap-2">
165
186
  <Settings className="w-8 h-8 text-theme-text-disabled" />
166
187
  <span>{showAllValues ? 'No computed values' : 'No user-supplied values'}</span>
@@ -179,6 +200,9 @@ export function ValuesViewer({
179
200
  <span className="text-sm font-medium text-theme-text-secondary">
180
201
  {isEditing ? 'Editing Values' : showAllValues ? 'All Values (Computed)' : 'User-Supplied Values'}
181
202
  </span>
203
+ {isHistoricalRevision && !isEditing && (
204
+ <span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">revision {revision}</span>
205
+ )}
182
206
  {isEditing && (
183
207
  <span className="badge-sm bg-amber-500/20 text-amber-400 border-amber-500/30">
184
208
  unsaved
@@ -218,7 +242,7 @@ export function ValuesViewer({
218
242
  </button>
219
243
  <button
220
244
  onClick={handlePreview}
221
- disabled={!!yamlError || previewMutation.isPending}
245
+ disabled={!!yamlError || previewMutation.isPending || isHistoricalRevision}
222
246
  className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded border border-theme-border disabled:opacity-50 disabled:cursor-not-allowed"
223
247
  >
224
248
  {previewMutation.isPending ? (
@@ -231,7 +255,7 @@ export function ValuesViewer({
231
255
  <Tooltip content={!canHelmWrite ? helmActReason : ''}>
232
256
  <button
233
257
  onClick={handleApply}
234
- disabled={!!yamlError || applyMutation.isPending || !canHelmWrite}
258
+ disabled={!!yamlError || applyMutation.isPending || !canHelmWrite || isHistoricalRevision}
235
259
  className="flex items-center gap-1 px-2.5 py-1 text-xs btn-brand rounded disabled:cursor-not-allowed disabled:pointer-events-none"
236
260
  >
237
261
  {applyMutation.isPending ? (
@@ -247,6 +271,12 @@ export function ValuesViewer({
247
271
  </div>
248
272
  </div>
249
273
 
274
+ {isHistoricalRevision && (
275
+ <div className="mb-3 rounded border border-theme-border bg-theme-elevated/40 px-3 py-2 text-xs text-theme-text-secondary">
276
+ Viewing historical values. Switch back to the latest revision before editing or applying changes.
277
+ </div>
278
+ )}
279
+
250
280
  {/* Error message */}
251
281
  {yamlError && (
252
282
  <div className="mb-3 px-3 py-2 text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded">
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
1
+ import { useState, type ReactNode } from 'react'
2
2
  import type { DashboardResponse, DashboardMetrics, DashboardCRDCount } from '../../api/client'
3
3
  import { HealthRing } from './HealthRing'
4
4
  import {
@@ -30,6 +30,9 @@ interface ClusterHealthCardProps {
30
30
  onNavigateToView: () => void
31
31
  onWarningEventsClick?: () => void
32
32
  onIssuesClick?: () => void
33
+ // Freshness/refresh control for the dashboard poll — rendered under the
34
+ // cluster metadata so the overview carries a freshness signal without a band.
35
+ freshness?: ReactNode
33
36
  }
34
37
 
35
38
  function getMetricsInstallHint(platform: string): string {
@@ -121,6 +124,7 @@ export function ClusterHealthCard({
121
124
  onNavigateToView,
122
125
  onWarningEventsClick,
123
126
  onIssuesClick,
127
+ freshness,
124
128
  }: ClusterHealthCardProps) {
125
129
  void _topCRDs // Reserved for future CRD display
126
130
 
@@ -254,6 +258,7 @@ export function ClusterHealthCard({
254
258
  </Tooltip>
255
259
  )}
256
260
  </div>
261
+ {freshness && <div className="mt-2">{freshness}</div>}
257
262
  {nodeVersionSkew && (
258
263
  <Tooltip
259
264
  content={