@skyhook-io/radar-app 1.8.2 → 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.
@@ -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
  }
@@ -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">
@@ -109,9 +109,9 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
109
109
  },
110
110
  {
111
111
  name: 'diagnose',
112
- desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers get status summary + parsed related issues.',
112
+ desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers, including Flux HelmRelease, get status summary + parsed related issues.',
113
113
  params: [
114
- { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or helmrelease' },
114
+ { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or Flux HelmRelease' },
115
115
  { arg: 'namespace', required: true, desc: 'resource namespace' },
116
116
  { arg: 'name', required: true, desc: 'resource name' },
117
117
  { arg: 'container', desc: 'specific container (defaults to all)' },
@@ -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 Kubernetes timeline. Helm release history is separate; use list_helm_releases or get_helm_release include=history,operations for failed upgrades and rollbacks.',
129
+ desc: 'Recent meaningful changes from the Kubernetes timeline plus native Helm deployment history (source=helm). Includes failed upgrades, rollbacks, and current Helm revisions; sourcesErrored marks partial source failures.',
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)' },
@@ -152,13 +152,13 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
152
152
  },
153
153
  {
154
154
  name: 'get_helm_release',
155
- desc: 'Detailed Helm release info with owned resources, health, Flux ownership, and current lastOperation; include history and operations for the full revision trail.',
155
+ desc: 'Detailed Helm release info with owned resources, health, Flux ownership, current lastOperation, hooks, and failed/running hook diagnostics with live Job/Pod/Event/redacted-log evidence when available.',
156
156
  params: [
157
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, operations, diff' },
160
- { arg: 'diff_revision_1', desc: 'first revision for diff' },
161
- { arg: 'diff_revision_2', desc: 'second revision for diff (defaults to current)' },
159
+ { arg: 'include', desc: 'values, history, operations, diff, values_diff, notes_diff, resource_diff' },
160
+ { arg: 'diff_revision_1', desc: 'first revision for any diff include' },
161
+ { arg: 'diff_revision_2', desc: 'second revision for any diff include (defaults to current)' },
162
162
  ],
163
163
  },
164
164
  {
@@ -172,7 +172,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
172
172
  },
173
173
  {
174
174
  name: 'issues',
175
- desc: 'Ranked list of what is broken right now — failing workloads, dangling references, scheduling blockers, and false CRD conditions. Live operational state (distinct from get_cluster_audit posture).',
175
+ desc: 'Ranked list of what is broken right now — failing workloads, active native Helm release failures or stuck pending operations, dangling references, scheduling blockers, and false CRD conditions. Native Helm rows use group=helm.sh.',
176
176
  params: [
177
177
  { arg: 'namespace', desc: 'filter to one namespace' },
178
178
  { arg: 'severity', desc: 'comma-separated: critical, warning' },
@@ -1,8 +1,10 @@
1
1
  import { useState, useRef, useEffect } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { Plug, ChevronDown, Loader2, Globe, Monitor, Copy, Check, X, Terminal } from 'lucide-react'
3
4
  import { clsx } from 'clsx'
4
- import { useAvailablePorts, useClusterInfo, AvailablePort } from '../../api/client'
5
+ import { useAvailablePorts, AvailablePort } from '../../api/client'
5
6
  import { useStartPortForward } from './PortForwardManager'
7
+ import { useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
6
8
  import { validatePort } from '@skyhook-io/k8s-ui/utils/validators'
7
9
  import { Tooltip } from '../ui/Tooltip'
8
10
 
@@ -22,6 +24,13 @@ interface KubectlDialogInfo {
22
24
  port: number
23
25
  }
24
26
 
27
+ // kubectl port-forward (and the live forward, which uses the same transport) is
28
+ // TCP-only — UDP/SCTP can't be forwarded (kubernetes/kubernetes#47862). Treat an
29
+ // unset protocol as TCP.
30
+ export function isPortForwardable(protocol?: string): boolean {
31
+ return (protocol || 'TCP').toUpperCase() === 'TCP'
32
+ }
33
+
25
34
  function buildKubectlCommand(type: 'pod' | 'service', namespace: string, name: string, localPort: number, remotePort: number) {
26
35
  const resource = type === 'pod' ? `pod/${name}` : `svc/${name}`
27
36
  const portArg = localPort === remotePort ? `${remotePort}` : `${localPort}:${remotePort}`
@@ -70,17 +79,23 @@ function KubectlCommandDialog({
70
79
 
71
80
  useEffect(() => {
72
81
  const handleKeyDown = (e: KeyboardEvent) => {
73
- if (e.key === 'Escape') onClose()
82
+ if (e.key !== 'Escape') return
83
+ // Capture + stopPropagation so Escape closes only this dialog, not the
84
+ // drawer behind it (whose Escape shortcut listens in the bubble phase).
85
+ e.stopPropagation()
86
+ onClose()
74
87
  }
75
- document.addEventListener('keydown', handleKeyDown)
76
- return () => document.removeEventListener('keydown', handleKeyDown)
88
+ document.addEventListener('keydown', handleKeyDown, true)
89
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
77
90
  }, [onClose])
78
91
 
79
92
  useEffect(() => {
80
93
  dialogRef.current?.focus()
81
94
  }, [])
82
95
 
83
- return (
96
+ // Portal to <body>: the drawer is a transformed ancestor that would otherwise
97
+ // trap this position:fixed dialog inside the drawer instead of centering it.
98
+ return createPortal(
84
99
  <div className="fixed inset-0 z-50 flex items-center justify-center">
85
100
  <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
86
101
  <div
@@ -103,7 +118,7 @@ function KubectlCommandDialog({
103
118
 
104
119
  <div className="p-4 space-y-3">
105
120
  <p className="text-sm text-theme-text-secondary">
106
- Radar is running in-cluster, so port forwarding must be run from your local terminal.
121
+ Forward this port to your own machine run it from your terminal:
107
122
  </p>
108
123
  <div className="flex flex-col gap-1">
109
124
  <div className="flex items-center gap-2 text-sm text-theme-text-secondary">
@@ -147,12 +162,13 @@ function KubectlCommandDialog({
147
162
  {copied ? 'Copied' : copyFallback ? 'Press Ctrl+C' : 'Copy'}
148
163
  </button>
149
164
  </div>
150
- <p className="text-xs text-theme-text-secondary">
151
- Requires kubectl and authentication to this cluster.
165
+ <p className="text-xs text-theme-text-tertiary">
166
+ You&apos;ll need <code className="inline-code">kubectl</code> and access to this cluster.
152
167
  </p>
153
168
  </div>
154
169
  </div>
155
- </div>
170
+ </div>,
171
+ document.body,
156
172
  )
157
173
  }
158
174
 
@@ -168,12 +184,16 @@ export function PortForwardButton({
168
184
  const [listenAddress, setListenAddress] = useState<'127.0.0.1' | '0.0.0.0'>('127.0.0.1')
169
185
  const dropdownRef = useRef<HTMLDivElement>(null)
170
186
 
171
- const { data: clusterInfo } = useClusterInfo()
187
+ const isLocal = useIsLocalDeployment()
172
188
  const { data, isLoading } = useAvailablePorts(type, namespace, name)
173
189
  const startPortForward = useStartPortForward()
174
190
 
175
191
  const ports = data?.ports || []
176
- const inCluster = clusterInfo?.inCluster ?? false
192
+ // Decide copy-command vs live forward from the SAME deployment signal that
193
+ // gates whether the button shows at all — so the two can't disagree (and we
194
+ // don't race a separate /cluster-info fetch that defaults to "not in-cluster").
195
+ // Cloud runs in-cluster too, so anything not-local uses the copy command.
196
+ const inCluster = !isLocal
177
197
  const isPending = !inCluster && startPortForward.isPending
178
198
  const resourceName = type === 'service' ? (serviceName || name) : name
179
199
 
@@ -204,10 +224,15 @@ export function PortForwardButton({
204
224
  }
205
225
 
206
226
  function renderButton() {
207
- // If no ports available, show disabled button
208
- if (!isLoading && ports.length === 0) {
227
+ // kubectl port-forward is TCP-only never offer UDP/SCTP ports as targets.
228
+ const forwardable = ports.filter((p) => isPortForwardable(p.protocol))
229
+
230
+ // No forwardable ports: disabled button. Distinguish "no ports at all" from
231
+ // "ports exist but are all UDP" so the operator isn't left guessing.
232
+ if (!isLoading && forwardable.length === 0) {
233
+ const udpOnly = ports.length > 0
209
234
  return (
210
- <Tooltip content="No ports available">
235
+ <Tooltip content={udpOnly ? "kubectl port-forward doesn't support UDP" : 'No ports available'}>
211
236
  <button
212
237
  disabled
213
238
  className={clsx(
@@ -216,18 +241,18 @@ export function PortForwardButton({
216
241
  )}
217
242
  >
218
243
  <Plug className="w-4 h-4" />
219
- No Ports
244
+ {udpOnly ? 'No TCP Ports' : 'No Ports'}
220
245
  </button>
221
246
  </Tooltip>
222
247
  )
223
248
  }
224
249
 
225
- // If only one port, forward directly on click (most common case)
226
- if (ports.length === 1) {
250
+ // If only one forwardable port, forward directly on click (most common case)
251
+ if (forwardable.length === 1) {
227
252
  return (
228
- <Tooltip content={`Port forward to ${ports[0].port}`}>
253
+ <Tooltip content={`Port forward to ${forwardable[0].port}`}>
229
254
  <button
230
- onClick={() => handlePortSelect(ports[0])}
255
+ onClick={() => handlePortSelect(forwardable[0])}
231
256
  disabled={isPending}
232
257
  className={clsx(
233
258
  'flex items-center gap-2 px-3 py-2 bg-theme-elevated text-theme-text-primary text-sm rounded-lg hover:bg-theme-hover transition-colors disabled:opacity-50 disabled:pointer-events-none',
@@ -239,7 +264,7 @@ export function PortForwardButton({
239
264
  ) : (
240
265
  <Plug className="w-4 h-4" />
241
266
  )}
242
- Forward :{ports[0].port}
267
+ Forward :{forwardable[0].port}
243
268
  </button>
244
269
  </Tooltip>
245
270
  )
@@ -306,7 +331,7 @@ export function PortForwardButton({
306
331
  <div className="px-2 py-1.5 text-xs text-theme-text-disabled border-b border-theme-border">
307
332
  Select port to forward
308
333
  </div>
309
- {ports.map((port, i) => (
334
+ {forwardable.map((port, i) => (
310
335
  <button
311
336
  key={i}
312
337
  onClick={() => handlePortSelect(port)}
@@ -355,11 +380,15 @@ export function PortForwardInlineButton({
355
380
  protocol = 'TCP',
356
381
  disabled = false,
357
382
  }: PortForwardInlineButtonProps) {
358
- const { data: clusterInfo } = useClusterInfo()
383
+ const isLocal = useIsLocalDeployment()
359
384
  const startPortForward = useStartPortForward()
360
385
  const [dialogInfo, setDialogInfo] = useState<KubectlDialogInfo | null>(null)
361
386
 
362
- const inCluster = clusterInfo?.inCluster ?? false
387
+ // Decide copy-command vs live forward from the SAME deployment signal that
388
+ // gates whether the button shows at all — so the two can't disagree (and we
389
+ // don't race a separate /cluster-info fetch that defaults to "not in-cluster").
390
+ // Cloud runs in-cluster too, so anything not-local uses the copy command.
391
+ const inCluster = !isLocal
363
392
  const isPending = !inCluster && startPortForward.isPending
364
393
 
365
394
  const handleClick = (e: React.MouseEvent) => {
@@ -378,15 +407,30 @@ export function PortForwardInlineButton({
378
407
  }
379
408
  }
380
409
 
410
+ // UDP/SCTP can't be port-forwarded — show a muted, non-interactive hint that
411
+ // explains why rather than a button that would copy a command that can't work.
412
+ if (!isPortForwardable(protocol)) {
413
+ return (
414
+ <Tooltip content="kubectl port-forward doesn't support UDP">
415
+ <span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-theme-elevated rounded text-xs text-theme-text-tertiary opacity-60 cursor-default">
416
+ {port}/{protocol}
417
+ <Plug className="w-3 h-3" />
418
+ </span>
419
+ </Tooltip>
420
+ )
421
+ }
422
+
381
423
  return (
382
424
  <>
383
- <Tooltip content={`Port forward ${port}`}>
425
+ <Tooltip content={inCluster ? 'Copy a kubectl port-forward command' : `Port forward ${port}`}>
384
426
  <button
385
427
  onClick={handleClick}
386
428
  disabled={disabled || isPending}
387
429
  className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-theme-elevated hover:bg-accent-muted rounded text-xs transition-colors disabled:opacity-50 disabled:hover:bg-theme-elevated disabled:pointer-events-none"
388
430
  >
389
- {port}/{protocol}
431
+ {/* In-cluster this opens a copy-command dialog rather than forwarding now;
432
+ the trailing "…" signals "opens a dialog" (it doesn't fire immediately). */}
433
+ {port}/{protocol}{inCluster ? '…' : ''}
390
434
  {isPending ? (
391
435
  <Loader2 className="w-3 h-3 animate-spin" />
392
436
  ) : (