@skyhook-io/radar-app 1.0.2 → 1.1.0

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.
@@ -42,11 +42,44 @@ interface PortForwardSession {
42
42
  localPort: number
43
43
  listenAddress: string
44
44
  serviceName?: string
45
+ servicePort?: number
46
+ scheme?: 'http' | 'https'
45
47
  startedAt: string
46
48
  status: 'running' | 'stopped' | 'error'
47
49
  error?: string
48
50
  }
49
51
 
52
+ function sessionUrl(session: PortForwardSession): string {
53
+ return `${session.scheme || 'http'}://localhost:${session.localPort}`
54
+ }
55
+
56
+ function formatPortLabel(session: PortForwardSession): string {
57
+ if (session.servicePort && session.servicePort !== session.podPort) {
58
+ return `${session.servicePort} → ${session.podPort}`
59
+ }
60
+ return String(session.podPort)
61
+ }
62
+
63
+ // Build the recreate request body for toggle-listen / change-port flows.
64
+ // When the original session was service-resolved, the recreate must also go
65
+ // through the service path (servicePort + serviceName). Sending podName+podPort
66
+ // would skip resolution and validate against the pod's declared containerPorts,
67
+ // which can fail even though the original session worked: services can route to
68
+ // any port the container actually listens on, regardless of containerPort
69
+ // declarations. Going through service also re-resolves to a currently-running
70
+ // pod if the original has since been replaced.
71
+ function buildRecreateBody(session: PortForwardSession, overrides: { localPort: number; listenAddress: string }) {
72
+ const base = {
73
+ namespace: session.namespace,
74
+ localPort: overrides.localPort,
75
+ listenAddress: overrides.listenAddress,
76
+ }
77
+ if (session.serviceName && session.servicePort) {
78
+ return { ...base, serviceName: session.serviceName, podPort: session.servicePort }
79
+ }
80
+ return { ...base, podName: session.podName, podPort: session.podPort }
81
+ }
82
+
50
83
  // --- Shared query ------------------------------------------------------------
51
84
 
52
85
  function usePortForwardQuery() {
@@ -430,14 +463,7 @@ export function PortForwardPanel() {
430
463
  const res = await fetch(apiUrl('/portforwards'), {
431
464
  method: 'POST',
432
465
  headers: { 'Content-Type': 'application/json' },
433
- body: JSON.stringify({
434
- namespace: session.namespace,
435
- podName: session.podName || undefined,
436
- serviceName: session.serviceName || undefined,
437
- podPort: session.podPort,
438
- localPort: session.localPort,
439
- listenAddress: newAddress,
440
- }),
466
+ body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
441
467
  })
442
468
  if (!res.ok) {
443
469
  const error = await res.json().catch(() => ({}))
@@ -484,14 +510,7 @@ export function PortForwardPanel() {
484
510
  const res = await fetch(apiUrl('/portforwards'), {
485
511
  method: 'POST',
486
512
  headers: { 'Content-Type': 'application/json' },
487
- body: JSON.stringify({
488
- namespace: session.namespace,
489
- podName: session.podName || undefined,
490
- serviceName: session.serviceName || undefined,
491
- podPort: session.podPort,
492
- localPort: newPort,
493
- listenAddress: session.listenAddress,
494
- }),
513
+ body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
495
514
  })
496
515
  if (!res.ok) {
497
516
  const error = await res.json().catch(() => ({}))
@@ -521,7 +540,7 @@ export function PortForwardPanel() {
521
540
  async (session: PortForwardSession) => {
522
541
  commitInteraction()
523
542
  try {
524
- await navigator.clipboard.writeText(`http://localhost:${session.localPort}`)
543
+ await navigator.clipboard.writeText(sessionUrl(session))
525
544
  } catch (err) {
526
545
  // Clipboard API can reject in non-secure contexts, denied permissions, or
527
546
  // when the document isn't focused. Surface the failure — the checkmark
@@ -544,7 +563,7 @@ export function PortForwardPanel() {
544
563
  const handleOpenUrl = useCallback(
545
564
  (session: PortForwardSession) => {
546
565
  commitInteraction()
547
- openExternal(`http://localhost:${session.localPort}`)
566
+ openExternal(sessionUrl(session))
548
567
  },
549
568
  [commitInteraction]
550
569
  )
@@ -626,7 +645,7 @@ export function PortForwardPanel() {
626
645
  </div>
627
646
 
628
647
  {/* Sessions list */}
629
- <div className="max-h-64 overflow-y-auto">
648
+ <div className="max-h-[28rem] overflow-y-auto">
630
649
  {isQueryError ? (
631
650
  <div className="p-3 text-xs bg-red-500/10 border-b border-theme-border">
632
651
  <div className={clsx('badge-sm mb-1 inline-block', SEVERITY_BADGE.error)}>
@@ -651,37 +670,84 @@ export function PortForwardPanel() {
651
670
  <div
652
671
  key={session.id}
653
672
  className={clsx(
654
- 'p-3',
673
+ 'p-3 space-y-1',
655
674
  session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
656
675
  )}
657
676
  >
677
+ {/* Row 1: status dot + name | stop button */}
658
678
  <div className="flex items-start justify-between gap-2">
659
- <div className="flex-1 min-w-0">
660
- <div className="flex items-center gap-2">
661
- <span
662
- className={clsx(
663
- 'w-2 h-2 rounded-full shrink-0',
664
- session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
665
- )}
666
- />
667
- <span className="text-sm text-theme-text-primary font-medium truncate">
668
- {session.serviceName || session.podName}
669
- </span>
670
- {session.status === 'error' && (
671
- <span className={clsx('badge-sm', SEVERITY_BADGE.error)}>Failed</span>
679
+ <div className="flex items-start gap-2 min-w-0 flex-1">
680
+ <span
681
+ className={clsx(
682
+ 'w-2 h-2 rounded-full shrink-0 mt-[7px]',
683
+ session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
672
684
  )}
673
- </div>
674
- <div className="mt-1 text-xs text-theme-text-disabled">
675
- {session.namespace} · Port {session.podPort}
676
- </div>
677
- {session.status === 'error' && session.error && (
678
- <div className="mt-1.5 text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
679
- {session.error}
680
- </div>
685
+ />
686
+ <span className="text-sm text-theme-text-primary font-medium break-all line-clamp-2">
687
+ {session.serviceName || session.podName}
688
+ </span>
689
+ {session.status === 'error' && (
690
+ <span className={clsx('badge-sm shrink-0', SEVERITY_BADGE.error)}>Failed</span>
681
691
  )}
692
+ </div>
693
+ <div className="flex items-center gap-0.5 shrink-0">
682
694
  {session.status === 'running' && (
683
- <div className="mt-1.5 flex items-center gap-2">
684
- {editingPortId === session.id ? (
695
+ <Tooltip
696
+ content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
697
+ delay={300} position="bottom" disabled={!isPanelOpen}
698
+ >
699
+ <button
700
+ onClick={() => toggleListenAddress(session)}
701
+ disabled={togglingId === session.id || changingPortId === session.id}
702
+ className={clsx(
703
+ 'flex items-center justify-center p-1.5 rounded transition-colors',
704
+ session.listenAddress === '0.0.0.0'
705
+ ? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
706
+ : 'text-theme-text-disabled hover:text-theme-text-primary hover:bg-theme-hover'
707
+ )}
708
+ >
709
+ {togglingId === session.id ? (
710
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
711
+ ) : session.listenAddress === '0.0.0.0' ? (
712
+ <Globe className="w-3.5 h-3.5" />
713
+ ) : (
714
+ <Monitor className="w-3.5 h-3.5" />
715
+ )}
716
+ </button>
717
+ </Tooltip>
718
+ )}
719
+ <Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
720
+ <button
721
+ onClick={() => {
722
+ commitInteraction()
723
+ stopPortForward(session.id)
724
+ }}
725
+ disabled={stoppingIds.has(session.id)}
726
+ className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
727
+ >
728
+ <Trash2 className="w-3.5 h-3.5" />
729
+ </button>
730
+ </Tooltip>
731
+ </div>
732
+ </div>
733
+
734
+ {/* Row 2: namespace · port translation */}
735
+ <div className="text-xs text-theme-text-disabled">
736
+ {session.namespace} · {formatPortLabel(session)}
737
+ </div>
738
+
739
+ {/* Row 2.5: error message */}
740
+ {session.status === 'error' && session.error && (
741
+ <div className="text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
742
+ {session.error}
743
+ </div>
744
+ )}
745
+
746
+ {/* Row 3: URL (+ optional toggle) | copy + open */}
747
+ {session.status === 'running' && (
748
+ <div className="pt-0.5 flex items-center justify-between gap-2">
749
+ <div className="flex items-center gap-1.5 min-w-0">
750
+ {editingPortId === session.id ? (
685
751
  <div className="flex items-center text-xs bg-theme-base rounded text-accent-text font-mono">
686
752
  <span className="pl-2 py-1 text-theme-text-disabled select-none">
687
753
  {session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
@@ -724,6 +790,7 @@ export function PortForwardPanel() {
724
790
  />
725
791
  </div>
726
792
  ) : (
793
+ <>
727
794
  <Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
728
795
  <code
729
796
  className={clsx(
@@ -747,74 +814,33 @@ export function PortForwardPanel() {
747
814
  <PenLine className="w-3 h-3 text-theme-text-disabled opacity-0 group-hover/port:opacity-100 transition-opacity" />
748
815
  </code>
749
816
  </Tooltip>
817
+ </>
818
+ )}
819
+ </div>
820
+ <div className="flex items-center gap-0.5 shrink-0">
821
+ <Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
822
+ <button
823
+ onClick={() => handleCopyUrl(session)}
824
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
825
+ >
826
+ {copiedId === session.id ? (
827
+ <Check className="w-3.5 h-3.5 text-green-400" />
828
+ ) : (
829
+ <Copy className="w-3.5 h-3.5" />
750
830
  )}
751
- <Tooltip
752
- content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
753
- delay={300} position="bottom" disabled={!isPanelOpen}
754
- >
755
- <button
756
- onClick={() => toggleListenAddress(session)}
757
- disabled={togglingId === session.id || changingPortId === session.id}
758
- className={clsx(
759
- 'flex items-center gap-1 px-1.5 py-0.5 text-xs rounded transition-colors',
760
- session.listenAddress === '0.0.0.0'
761
- ? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
762
- : 'bg-theme-elevated text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary'
763
- )}
764
- >
765
- {togglingId === session.id ? (
766
- <Loader2 className="w-3 h-3 animate-spin" />
767
- ) : session.listenAddress === '0.0.0.0' ? (
768
- <Globe className="w-3 h-3" />
769
- ) : (
770
- <Monitor className="w-3 h-3" />
771
- )}
772
- {session.listenAddress === '0.0.0.0' ? 'network' : 'local'}
773
- </button>
774
- </Tooltip>
775
- </div>
776
- )}
777
- </div>
778
-
779
- <div className="flex items-center gap-1 shrink-0">
780
- {session.status === 'running' && (
781
- <>
782
- <Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
783
- <button
784
- onClick={() => handleCopyUrl(session)}
785
- className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
786
- >
787
- {copiedId === session.id ? (
788
- <Check className="w-3.5 h-3.5 text-green-400" />
789
- ) : (
790
- <Copy className="w-3.5 h-3.5" />
791
- )}
792
- </button>
793
- </Tooltip>
794
- <Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
795
- <button
796
- onClick={() => handleOpenUrl(session)}
797
- className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
798
- >
799
- <ExternalLink className="w-3.5 h-3.5" />
800
- </button>
801
- </Tooltip>
802
- </>
803
- )}
804
- <Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
805
- <button
806
- onClick={() => {
807
- commitInteraction()
808
- stopPortForward(session.id)
809
- }}
810
- disabled={stoppingIds.has(session.id)}
811
- className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
812
- >
813
- <Trash2 className="w-3.5 h-3.5" />
814
- </button>
815
- </Tooltip>
831
+ </button>
832
+ </Tooltip>
833
+ <Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
834
+ <button
835
+ onClick={() => handleOpenUrl(session)}
836
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
837
+ >
838
+ <ExternalLink className="w-3.5 h-3.5" />
839
+ </button>
840
+ </Tooltip>
841
+ </div>
816
842
  </div>
817
- </div>
843
+ )}
818
844
  </div>
819
845
  ))}
820
846
  </div>
@@ -1,7 +1,7 @@
1
1
  import { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
- import { ApiError, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
4
+ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
5
5
  import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
6
6
  import { useAPIResources } from '../../api/apiResources'
7
7
  import { initNavigationMap } from '@skyhook-io/k8s-ui'
@@ -52,7 +52,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
52
52
  queryFn: async () => {
53
53
  const params = new URLSearchParams()
54
54
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
55
- return fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
55
+ const startedAt = performance.now()
56
+ debugNamespaceLog('resources:counts-fetch-start', { namespaces, params: params.toString() })
57
+ try {
58
+ return await fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
59
+ } finally {
60
+ debugNamespaceLog('resources:counts-fetch-end', {
61
+ namespaces,
62
+ params: params.toString(),
63
+ durationMs: Math.round(performance.now() - startedAt),
64
+ })
65
+ }
56
66
  },
57
67
  staleTime: 10000,
58
68
  refetchInterval: 60000, // Safety net — SSE k8s_event drives near-real-time invalidation
@@ -75,10 +85,25 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
75
85
  const params = new URLSearchParams()
76
86
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
77
87
  if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
88
+ const startedAt = performance.now()
89
+ debugNamespaceLog('resources:selected-kind-fetch-start', {
90
+ kind: selectedKind.name,
91
+ group: isSelectedCrd ? selectedKind.group : '',
92
+ namespaces,
93
+ params: params.toString(),
94
+ })
78
95
  const res = await fetch(apiUrl(`/resources/${selectedKind.name}?${params}`), {
79
96
  credentials: getCredentialsMode(),
80
97
  headers: getAuthHeaders(),
81
98
  })
99
+ debugNamespaceLog('resources:selected-kind-fetch-response', {
100
+ kind: selectedKind.name,
101
+ group: isSelectedCrd ? selectedKind.group : '',
102
+ namespaces,
103
+ params: params.toString(),
104
+ status: res.status,
105
+ durationMs: Math.round(performance.now() - startedAt),
106
+ })
82
107
  if (!res.ok) {
83
108
  const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
84
109
  throw new ApiError(errorData.error || `Failed to fetch ${selectedKind.name}`, res.status, errorData)
@@ -1,5 +1,6 @@
1
1
  // Re-export all resource utilities from the shared @skyhook-io/k8s-ui package.
2
2
  export * from '@skyhook-io/k8s-ui/components/resources/resource-utils'
3
3
 
4
- // Backward compatibility: re-export formatBytes (previously re-exported here)
4
+ // formatBytes lives in utils/format but is re-exported here so consumers
5
+ // can import it from the same module as the rest of the resource utilities.
5
6
  export { formatBytes } from '@skyhook-io/k8s-ui/utils/format'
@@ -26,8 +26,9 @@ import {
26
26
  import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
27
27
  import type { TimelineEvent, Topology } from '../../types'
28
28
  import type { NavigateToResource } from '../../utils/navigation'
29
- import { kindToPlural } from '../../utils/navigation'
30
- import { PaneLoader, pluralize } from '@skyhook-io/k8s-ui'
29
+ import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
30
+ import { PaneLoader, pluralize, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
31
+ import { useNavigate } from 'react-router-dom'
31
32
  import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
32
33
  import { DiffViewer } from './DiffViewer'
33
34
  import { getOperationColor, getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
@@ -174,7 +175,20 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
174
175
  }
175
176
 
176
177
  export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces }: TimelineSwimlanesProps) {
178
+ const navigate = useNavigate()
177
179
  const hasLimitedAccess = useHasLimitedAccess()
180
+ // Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
181
+ // deep-link to GitOps detail rather than the resource drawer — the lane is
182
+ // already telling the user "this controller had changes/events"; the GitOps
183
+ // tab is the right place to investigate further.
184
+ const handleLaneOpen = useCallback((kind: string, namespace: string, name: string, group?: string) => {
185
+ const gitOpsPath = gitOpsRouteForKind(kind, namespace, name)
186
+ if (gitOpsPath) {
187
+ navigate(gitOpsPath)
188
+ return
189
+ }
190
+ onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
191
+ }, [navigate, onResourceClick])
178
192
  const containerRef = useRef<HTMLDivElement>(null)
179
193
  const searchInputRef = useRef<HTMLInputElement>(null)
180
194
  const [zoom, setZoom] = useState(1)
@@ -682,7 +696,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
682
696
  )}
683
697
  <div
684
698
  className="flex-1 min-w-0 cursor-pointer hover:bg-theme-surface/30 rounded px-1 -mx-1 group"
685
- onClick={() => onResourceClick?.({ kind: kindToPlural(lane.kind), namespace: lane.namespace, name: lane.name })}
699
+ onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
686
700
  >
687
701
  <div className="flex items-center gap-1">
688
702
  <span className={clsx(
@@ -760,7 +774,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
760
774
  <div className="flex">
761
775
  <div
762
776
  className="w-[19.25rem] shrink-0 border-r border-theme-border/50 pl-4 pr-3 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-theme-elevated/30 group"
763
- onClick={() => onResourceClick?.({ kind: kindToPlural(lane.kind), namespace: lane.namespace, name: lane.name })}
777
+ onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
764
778
  >
765
779
  <div className="flex-1 min-w-0">
766
780
  <div className="flex items-center gap-1">
@@ -812,7 +826,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
812
826
  {/* Child lane label - indented */}
813
827
  <div
814
828
  className="w-[19.25rem] shrink-0 border-r border-theme-border/50 pl-4 pr-3 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-theme-elevated/30 group"
815
- onClick={() => onResourceClick?.({ kind: kindToPlural(child.kind), namespace: child.namespace, name: child.name })}
829
+ onClick={() => handleLaneOpen(child.kind, child.namespace, child.name, child.group)}
816
830
  >
817
831
  <div className="flex-1 min-w-0">
818
832
  <div className="flex items-center gap-1">
@@ -1234,7 +1248,7 @@ function EventDetailPanel({ event, onClose, onResourceClick }: EventDetailPanelP
1234
1248
  {displayKind(event.kind)}
1235
1249
  </span>
1236
1250
  <button
1237
- onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name })}
1251
+ onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name, group: apiVersionToGroup(event.apiVersion) })}
1238
1252
  className="text-theme-text-primary font-medium hover:text-accent-text"
1239
1253
  >
1240
1254
  {event.name}
@@ -2,11 +2,12 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
2
2
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
3
3
  import { Search, X, ChevronRight } from 'lucide-react'
4
4
  import { Home, Network, List, Clock, Package, Activity, Sun, Stethoscope, DollarSign, ShieldCheck } from 'lucide-react'
5
+ import { GitBranch } from 'lucide-react'
5
6
  import { clsx } from 'clsx'
6
7
  import { useNamespaces, useContexts } from '../../api/client'
7
8
  import { CORE_RESOURCES, useAPIResources } from '../../api/apiResources'
8
9
  import { getResourceIcon } from '../../utils/resource-icons'
9
- type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'helm' | 'traffic' | 'cost' | 'audit'
10
+ type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'helm' | 'traffic' | 'cost' | 'audit' | 'gitops'
10
11
 
11
12
  interface CommandPaletteProps {
12
13
  onClose: () => void
@@ -154,9 +155,10 @@ export function CommandPalette({
154
155
  { view: 'resources', label: 'Resources', icon: List, shortcut: '3' },
155
156
  { view: 'timeline', label: 'Timeline', icon: Clock, shortcut: '4' },
156
157
  { view: 'helm', label: 'Helm', icon: Package, shortcut: '5' },
157
- { view: 'traffic', label: 'Traffic', icon: Activity, shortcut: '6' },
158
- { view: 'cost', label: 'Cost', icon: DollarSign, shortcut: '7' },
159
- { view: 'audit', label: 'Audit', icon: ShieldCheck, shortcut: '8' },
158
+ { view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: '6' },
159
+ { view: 'traffic', label: 'Traffic', icon: Activity, shortcut: '7' },
160
+ { view: 'cost', label: 'Cost', icon: DollarSign, shortcut: '8' },
161
+ { view: 'audit', label: 'Audit', icon: ShieldCheck, shortcut: '9' },
160
162
  ]
161
163
  for (const v of viewEntries) {
162
164
  result.push({
@@ -19,7 +19,7 @@ const CONTEXT_CATEGORIES: ShortcutCategory[] = ['Drawer', 'Dock']
19
19
 
20
20
  // Preferred ordering within the view section
21
21
  const VIEW_CATEGORY_ORDER: ShortcutCategory[] = [
22
- 'Search', 'Table', 'Resource Actions', 'Topology', 'Timeline', 'Helm',
22
+ 'Search', 'Table', 'Resource Actions', 'Topology', 'Timeline', 'Helm', 'GitOps',
23
23
  ]
24
24
 
25
25
  const VIEW_LABELS: Record<string, string> = {
@@ -28,6 +28,7 @@ const VIEW_LABELS: Record<string, string> = {
28
28
  resources: 'Resources',
29
29
  timeline: 'Timeline',
30
30
  helm: 'Helm',
31
+ gitops: 'GitOps',
31
32
  traffic: 'Traffic',
32
33
  }
33
34
 
@@ -8,6 +8,7 @@ import {
8
8
  useApplyDesktopUpdate,
9
9
  } from '../../api/client'
10
10
  import type { DesktopUpdateState } from '../../api/client'
11
+ import { WithTooltip } from './Tooltip'
11
12
 
12
13
  const DISMISSED_KEY = 'radar-update-dismissed'
13
14
 
@@ -117,7 +118,7 @@ export function UpdateNotification() {
117
118
  <UpdateIcon state={effectiveState} />
118
119
  </div>
119
120
  <div className="flex-1 min-w-0">
120
- <h4 className="text-sm font-medium text-theme-text-primary">
121
+ <h4 className="text-sm font-medium text-theme-text-primary pr-6">
121
122
  <UpdateTitle state={effectiveState} />
122
123
  </h4>
123
124
  <p className="text-xs text-theme-text-secondary mt-1">
@@ -140,15 +141,30 @@ export function UpdateNotification() {
140
141
 
141
142
  {/* CLI: show update command with copy button for package managers */}
142
143
  {!isDesktop && versionInfo.updateCommand ? (
143
- <button
144
- onClick={handleCopyCommand}
145
- className="flex items-center gap-2 mt-2 px-2 py-1.5 bg-theme-elevated rounded text-xs font-mono text-theme-text-primary hover:bg-theme-surface-hover transition-colors w-full"
146
- >
147
- <code className="flex-1 text-left truncate">{versionInfo.updateCommand}</code>
148
- <CopyIcon copied={copied} failed={copyFailed} />
149
- </button>
144
+ <>
145
+ <WithTooltip tip={versionInfo.updateCommand} delay={100}>
146
+ <button
147
+ onClick={handleCopyCommand}
148
+ className="flex items-center gap-2 mt-2 px-2 py-1.5 bg-theme-elevated rounded font-mono text-theme-text-primary hover:bg-theme-surface-hover transition-colors w-full"
149
+ >
150
+ <code className="flex-1 text-left truncate text-[11px]">{versionInfo.updateCommand}</code>
151
+ <CopyIcon copied={copied} failed={copyFailed} />
152
+ </button>
153
+ </WithTooltip>
154
+ {/* Direct installs may have placed the binary somewhere the install
155
+ script won't touch — surface a download link as a fallback. */}
156
+ {versionInfo.installMethod === 'direct' && versionInfo.releaseUrl && (
157
+ <a
158
+ href={versionInfo.releaseUrl}
159
+ target="_blank"
160
+ rel="noopener noreferrer"
161
+ className="inline-flex items-center gap-1 mt-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-secondary hover:underline"
162
+ >
163
+ or download from GitHub →
164
+ </a>
165
+ )}
166
+ </>
150
167
  ) : (
151
- /* Direct download - show release link */
152
168
  !isDesktop && versionInfo.releaseUrl && (
153
169
  <a
154
170
  href={versionInfo.releaseUrl}
@@ -161,17 +177,18 @@ export function UpdateNotification() {
161
177
  )
162
178
  )}
163
179
  </div>
164
- {/* Don't show dismiss during active update */}
165
- {effectiveState !== 'downloading' && effectiveState !== 'applying' && (
166
- <button
167
- onClick={handleDismiss}
168
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
169
- aria-label="Dismiss"
170
- >
171
- <X className="w-4 h-4" />
172
- </button>
173
- )}
174
180
  </div>
181
+ {/* Dismiss is absolute so it doesn't compress the chip's width.
182
+ fixed on the parent already establishes the positioning context. */}
183
+ {effectiveState !== 'downloading' && effectiveState !== 'applying' && (
184
+ <button
185
+ onClick={handleDismiss}
186
+ className="absolute top-2 right-2 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
187
+ aria-label="Dismiss"
188
+ >
189
+ <X className="w-4 h-4" />
190
+ </button>
191
+ )}
175
192
  </div>
176
193
  )
177
194
  }