@skyhook-io/radar-app 0.2.1 → 0.3.1

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 (180) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/assets/platform-icons/aws_eks.png +0 -0
  19. package/src/assets/platform-icons/azure-aks.svg +2 -0
  20. package/src/assets/platform-icons/google_kubernetes_engine.png +0 -0
  21. package/src/components/ConnectionErrorView.tsx +186 -70
  22. package/src/components/ContextSwitcher.tsx +63 -18
  23. package/src/components/DebugOverlay.tsx +5 -3
  24. package/src/components/NamespaceSwitcher.tsx +41 -0
  25. package/src/components/UserMenu.tsx +69 -21
  26. package/src/components/applications/ApplicationsView.tsx +936 -0
  27. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  28. package/src/components/audit/AuditView.tsx +65 -62
  29. package/src/components/compare/CompareViewRoute.tsx +124 -0
  30. package/src/components/compare/useCompareCandidates.ts +27 -0
  31. package/src/components/compare/useCompareLauncher.tsx +79 -0
  32. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  33. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  34. package/src/components/cost/CostTrendChart.tsx +106 -75
  35. package/src/components/cost/CostView.test.ts +12 -0
  36. package/src/components/cost/CostView.tsx +507 -223
  37. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  38. package/src/components/cost/CostViewTabs.tsx +40 -0
  39. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  40. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  41. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  42. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  43. package/src/components/cost/cloud-console.test.ts +39 -0
  44. package/src/components/cost/cloud-console.ts +81 -0
  45. package/src/components/cost/errors.ts +8 -0
  46. package/src/components/cost/format.test.ts +27 -0
  47. package/src/components/cost/format.ts +46 -0
  48. package/src/components/cost/kinds.ts +5 -0
  49. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  50. package/src/components/diagnose/AISettings.tsx +147 -0
  51. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  52. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  53. package/src/components/diagnose/Home.tsx +163 -0
  54. package/src/components/diagnose/InvestigationView.tsx +622 -0
  55. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  56. package/src/components/diagnose/launch.ts +65 -0
  57. package/src/components/diagnose/parts.tsx +1756 -0
  58. package/src/components/dock/BottomDock.tsx +2 -3
  59. package/src/components/dock/DockContext.tsx +1 -0
  60. package/src/components/dock/TerminalTab.tsx +1 -1
  61. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  62. package/src/components/dock/index.ts +1 -1
  63. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  64. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  65. package/src/components/execution/batch-run-actions.test.ts +48 -0
  66. package/src/components/execution/batch-run-actions.ts +24 -0
  67. package/src/components/execution/batch-timeline.test.ts +57 -0
  68. package/src/components/execution/batch-timeline.ts +46 -0
  69. package/src/components/execution/execution-definition.test.ts +208 -0
  70. package/src/components/execution/execution-definition.ts +245 -0
  71. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  72. package/src/components/gitops/GitOpsView.tsx +1042 -0
  73. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  74. package/src/components/helm/ChartBrowser.tsx +87 -31
  75. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  76. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  77. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  78. package/src/components/helm/HelmView.tsx +237 -96
  79. package/src/components/helm/InstallWizard.tsx +94 -38
  80. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  81. package/src/components/helm/OwnedResources.tsx +34 -59
  82. package/src/components/helm/RevisionHistory.tsx +52 -3
  83. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  84. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  85. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  86. package/src/components/helm/ValuesViewer.tsx +49 -53
  87. package/src/components/helm/helm-utils.ts +4 -0
  88. package/src/components/home/ActivitySummary.tsx +4 -1
  89. package/src/components/home/ClusterHealthCard.tsx +116 -52
  90. package/src/components/home/CostCard.tsx +21 -36
  91. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  92. package/src/components/home/HelmSummary.tsx +3 -1
  93. package/src/components/home/HomeView.tsx +339 -105
  94. package/src/components/home/MCPSetupDialog.tsx +29 -87
  95. package/src/components/home/TrafficSummary.tsx +2 -2
  96. package/src/components/home/mcpToolCatalog.ts +333 -0
  97. package/src/components/issues/IssuesPane.tsx +151 -0
  98. package/src/components/logs/LogsViewer.tsx +4 -1
  99. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  100. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  101. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  102. package/src/components/portforward/PortForwardButton.tsx +118 -47
  103. package/src/components/portforward/PortForwardManager.tsx +253 -131
  104. package/src/components/resource/HPACharts.tsx +237 -0
  105. package/src/components/resource/PVCUsageBar.tsx +59 -0
  106. package/src/components/resource/PrometheusCharts.tsx +160 -584
  107. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  108. package/src/components/resource/RestartChart.tsx +133 -0
  109. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  110. package/src/components/resource/RightsizingStrip.tsx +363 -0
  111. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  112. package/src/components/resources/CompositeRenderer.tsx +101 -0
  113. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  114. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  115. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  116. package/src/components/resources/ResourcesView.tsx +194 -17
  117. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  118. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  119. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  120. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  121. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  122. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  123. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  124. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  125. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  126. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  127. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  128. package/src/components/resources/renderers/index.ts +2 -0
  129. package/src/components/resources/resource-utils.ts +2 -1
  130. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  131. package/src/components/rightsizing/copy.test.ts +56 -0
  132. package/src/components/rightsizing/model.test.ts +227 -0
  133. package/src/components/rightsizing/model.ts +158 -0
  134. package/src/components/rightsizing/presentation.test.ts +104 -0
  135. package/src/components/rightsizing/presentation.ts +94 -0
  136. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  137. package/src/components/settings/SettingsDialog.tsx +1505 -165
  138. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  139. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  140. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  141. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  142. package/src/components/timeline/TimelineList.tsx +86 -13
  143. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  144. package/src/components/timeline/TimelineView.tsx +873 -24
  145. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  146. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  147. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  148. package/src/components/traffic/TrafficGraph.tsx +42 -24
  149. package/src/components/traffic/TrafficView.tsx +32 -19
  150. package/src/components/ui/CommandPalette.tsx +8 -215
  151. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  152. package/src/components/ui/Markdown.tsx +3 -3
  153. package/src/components/ui/Omnibar.tsx +602 -0
  154. package/src/components/ui/RadarOmnibar.tsx +52 -0
  155. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  156. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  157. package/src/components/ui/UpdateNotification.tsx +48 -36
  158. package/src/components/ui/command-items.ts +178 -0
  159. package/src/components/workload/WorkloadView.tsx +1342 -158
  160. package/src/context/ConnectionContext.tsx +146 -21
  161. package/src/context/DiagnoseCustomization.tsx +93 -0
  162. package/src/context/NavCustomization.tsx +75 -0
  163. package/src/context/TimelineSource.tsx +50 -0
  164. package/src/contexts/CapabilitiesContext.tsx +38 -8
  165. package/src/filter/FilterLocationBridge.tsx +30 -0
  166. package/src/hooks/useClusterLoadState.ts +73 -0
  167. package/src/hooks/useDocumentTitle.ts +25 -0
  168. package/src/hooks/useEventSource.ts +6 -0
  169. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  170. package/src/hooks/useMediaQuery.ts +21 -0
  171. package/src/hooks/useNavRailPinned.ts +46 -0
  172. package/src/hooks/useRecentResources.ts +49 -0
  173. package/src/index.css +162 -1
  174. package/src/index.ts +73 -1
  175. package/src/main.tsx +7 -5
  176. package/src/types/clusterLoadState.ts +33 -0
  177. package/src/types.ts +2 -0
  178. package/src/utils/auditBadges.ts +53 -0
  179. package/src/utils/navigation.ts +64 -1
  180. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,8 +1,13 @@
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'
8
+ import { validatePort } from '@skyhook-io/k8s-ui/utils/validators'
9
+ import { Tooltip } from '../ui/Tooltip'
10
+ import { Input } from '@skyhook-io/k8s-ui'
6
11
 
7
12
  interface PortForwardButtonProps {
8
13
  type: 'pod' | 'service'
@@ -20,6 +25,13 @@ interface KubectlDialogInfo {
20
25
  port: number
21
26
  }
22
27
 
28
+ // kubectl port-forward (and the live forward, which uses the same transport) is
29
+ // TCP-only — UDP/SCTP can't be forwarded (kubernetes/kubernetes#47862). Treat an
30
+ // unset protocol as TCP.
31
+ export function isPortForwardable(protocol?: string): boolean {
32
+ return (protocol || 'TCP').toUpperCase() === 'TCP'
33
+ }
34
+
23
35
  function buildKubectlCommand(type: 'pod' | 'service', namespace: string, name: string, localPort: number, remotePort: number) {
24
36
  const resource = type === 'pod' ? `pod/${name}` : `svc/${name}`
25
37
  const portArg = localPort === remotePort ? `${remotePort}` : `${localPort}:${remotePort}`
@@ -35,7 +47,13 @@ function KubectlCommandDialog({
35
47
  }) {
36
48
  const [copied, setCopied] = useState(false)
37
49
  const [copyFallback, setCopyFallback] = useState(false)
38
- const [localPort, setLocalPort] = useState(info.port)
50
+ // Track raw input separately from the validated port so the user
51
+ // always sees the characters they typed; the validated port (used to
52
+ // build the command) only updates when the input parses cleanly.
53
+ const [portInput, setPortInput] = useState(String(info.port))
54
+ const portValidation = validatePort(portInput)
55
+ const localPort = portValidation.valid ? portValidation.value : info.port
56
+ const portError = portValidation.valid ? null : portValidation.error
39
57
  const commandRef = useRef<HTMLElement>(null)
40
58
  const dialogRef = useRef<HTMLDivElement>(null)
41
59
 
@@ -62,17 +80,23 @@ function KubectlCommandDialog({
62
80
 
63
81
  useEffect(() => {
64
82
  const handleKeyDown = (e: KeyboardEvent) => {
65
- if (e.key === 'Escape') onClose()
83
+ if (e.key !== 'Escape') return
84
+ // Capture + stopPropagation so Escape closes only this dialog, not the
85
+ // drawer behind it (whose Escape shortcut listens in the bubble phase).
86
+ e.stopPropagation()
87
+ onClose()
66
88
  }
67
- document.addEventListener('keydown', handleKeyDown)
68
- return () => document.removeEventListener('keydown', handleKeyDown)
89
+ document.addEventListener('keydown', handleKeyDown, true)
90
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
69
91
  }, [onClose])
70
92
 
71
93
  useEffect(() => {
72
94
  dialogRef.current?.focus()
73
95
  }, [])
74
96
 
75
- return (
97
+ // Portal to <body>: the drawer is a transformed ancestor that would otherwise
98
+ // trap this position:fixed dialog inside the drawer instead of centering it.
99
+ return createPortal(
76
100
  <div className="fixed inset-0 z-50 flex items-center justify-center">
77
101
  <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
78
102
  <div
@@ -95,23 +119,36 @@ function KubectlCommandDialog({
95
119
 
96
120
  <div className="p-4 space-y-3">
97
121
  <p className="text-sm text-theme-text-secondary">
98
- Radar is running in-cluster, so port forwarding must be run from your local terminal.
122
+ Forward this port to your own machine run it from your terminal:
99
123
  </p>
100
- <div className="flex items-center gap-2 text-sm text-theme-text-secondary">
101
- <label htmlFor="local-port">Local port:</label>
102
- <input
103
- id="local-port"
104
- type="number"
105
- min={1}
106
- max={65535}
107
- value={localPort}
108
- onChange={(e) => {
109
- const val = Number(e.target.value)
110
- if (val >= 1 && val <= 65535) setLocalPort(val)
111
- else if (e.target.value === '') setLocalPort(info.port)
112
- }}
113
- className="w-20 bg-theme-base border border-theme-border rounded px-2 py-1 text-sm text-theme-text-primary font-mono text-center"
114
- />
124
+ <div className="flex flex-col gap-1">
125
+ <div className="flex items-center gap-2 text-sm text-theme-text-secondary">
126
+ <label htmlFor="local-port">Local port:</label>
127
+ <Input
128
+ id="local-port"
129
+ inputMode="numeric"
130
+ value={portInput}
131
+ onChange={(e) => setPortInput(e.target.value)}
132
+ aria-invalid={portError ? true : undefined}
133
+ aria-describedby="local-port-help"
134
+ className={clsx(
135
+ 'w-24 bg-theme-base border rounded px-2 py-1 text-sm text-theme-text-primary font-mono text-center',
136
+ portError
137
+ ? 'border-red-500/60 focus:outline-none focus:ring-2 focus:ring-red-500'
138
+ : 'border-theme-border',
139
+ )}
140
+ />
141
+ {portError && (
142
+ <span className="text-xs text-red-400">
143
+ using {info.port}
144
+ </span>
145
+ )}
146
+ </div>
147
+ {portError && (
148
+ <p id="local-port-help" className="text-xs text-red-400">
149
+ {portError.charAt(0).toUpperCase() + portError.slice(1)}.
150
+ </p>
151
+ )}
115
152
  </div>
116
153
  <div className="flex items-center gap-2">
117
154
  <code ref={commandRef} className="flex-1 text-sm bg-theme-base rounded px-3 py-2 text-blue-400 font-mono select-all">
@@ -125,12 +162,13 @@ function KubectlCommandDialog({
125
162
  {copied ? 'Copied' : copyFallback ? 'Press Ctrl+C' : 'Copy'}
126
163
  </button>
127
164
  </div>
128
- <p className="text-xs text-theme-text-secondary">
129
- 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.
130
167
  </p>
131
168
  </div>
132
169
  </div>
133
- </div>
170
+ </div>,
171
+ document.body,
134
172
  )
135
173
  }
136
174
 
@@ -146,12 +184,16 @@ export function PortForwardButton({
146
184
  const [listenAddress, setListenAddress] = useState<'127.0.0.1' | '0.0.0.0'>('127.0.0.1')
147
185
  const dropdownRef = useRef<HTMLDivElement>(null)
148
186
 
149
- const { data: clusterInfo } = useClusterInfo()
187
+ const isLocal = useIsLocalDeployment()
150
188
  const { data, isLoading } = useAvailablePorts(type, namespace, name)
151
189
  const startPortForward = useStartPortForward()
152
190
 
153
191
  const ports = data?.ports || []
154
- 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
155
197
  const isPending = !inCluster && startPortForward.isPending
156
198
  const resourceName = type === 'service' ? (serviceName || name) : name
157
199
 
@@ -182,42 +224,49 @@ export function PortForwardButton({
182
224
  }
183
225
 
184
226
  function renderButton() {
185
- // If no ports available, show disabled button
186
- 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
187
234
  return (
235
+ <Tooltip content={udpOnly ? "kubectl port-forward doesn't support UDP" : 'No ports available'}>
188
236
  <button
189
237
  disabled
190
238
  className={clsx(
191
- 'flex items-center gap-2 px-3 py-2 bg-theme-elevated text-theme-text-primary text-sm rounded-lg opacity-50 cursor-not-allowed',
239
+ 'flex items-center gap-2 px-3 py-2 bg-theme-elevated text-theme-text-primary text-sm rounded-lg opacity-50 cursor-not-allowed disabled:pointer-events-none',
192
240
  className
193
241
  )}
194
- title="No ports available"
195
242
  >
196
243
  <Plug className="w-4 h-4" />
197
- No Ports
244
+ {udpOnly ? 'No TCP Ports' : 'No Ports'}
198
245
  </button>
246
+ </Tooltip>
199
247
  )
200
248
  }
201
249
 
202
- // If only one port, forward directly on click (most common case)
203
- if (ports.length === 1) {
250
+ // If only one forwardable port, forward directly on click (most common case)
251
+ if (forwardable.length === 1) {
204
252
  return (
253
+ <Tooltip content={`Port forward to ${forwardable[0].port}`}>
205
254
  <button
206
- onClick={() => handlePortSelect(ports[0])}
255
+ onClick={() => handlePortSelect(forwardable[0])}
207
256
  disabled={isPending}
208
257
  className={clsx(
209
- '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',
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',
210
259
  className
211
260
  )}
212
- title={`Port forward to ${ports[0].port}`}
213
261
  >
214
262
  {isPending ? (
215
263
  <Loader2 className="w-4 h-4 animate-spin" />
216
264
  ) : (
217
265
  <Plug className="w-4 h-4" />
218
266
  )}
219
- Forward :{ports[0].port}
267
+ Forward :{forwardable[0].port}
220
268
  </button>
269
+ </Tooltip>
221
270
  )
222
271
  }
223
272
 
@@ -248,6 +297,7 @@ export function PortForwardButton({
248
297
  <div className="px-3 py-2 border-b border-theme-border">
249
298
  <div className="text-xs text-theme-text-disabled mb-2">Listen on</div>
250
299
  <div className="flex gap-1">
300
+ <Tooltip content="Only accessible from this machine" wrapperClassName="flex-1">
251
301
  <button
252
302
  onClick={(e) => { e.stopPropagation(); setListenAddress('127.0.0.1') }}
253
303
  className={clsx(
@@ -256,11 +306,12 @@ export function PortForwardButton({
256
306
  ? 'btn-brand-toggle'
257
307
  : 'bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-primary'
258
308
  )}
259
- title="Only accessible from this machine"
260
309
  >
261
310
  <Monitor className="w-3 h-3" />
262
311
  localhost
263
312
  </button>
313
+ </Tooltip>
314
+ <Tooltip content="Accessible from other machines on the network" wrapperClassName="flex-1">
264
315
  <button
265
316
  onClick={(e) => { e.stopPropagation(); setListenAddress('0.0.0.0') }}
266
317
  className={clsx(
@@ -269,25 +320,25 @@ export function PortForwardButton({
269
320
  ? 'bg-amber-600 text-white'
270
321
  : 'bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-primary'
271
322
  )}
272
- title="Accessible from other machines on the network"
273
323
  >
274
324
  <Globe className="w-3 h-3" />
275
325
  all interfaces
276
326
  </button>
327
+ </Tooltip>
277
328
  </div>
278
329
  </div>
279
330
  )}
280
331
  <div className="px-2 py-1.5 text-xs text-theme-text-disabled border-b border-theme-border">
281
332
  Select port to forward
282
333
  </div>
283
- {ports.map((port, i) => (
334
+ {forwardable.map((port, i) => (
284
335
  <button
285
336
  key={i}
286
337
  onClick={() => handlePortSelect(port)}
287
338
  className="w-full px-3 py-2 text-left text-sm text-theme-text-primary hover:bg-theme-elevated flex items-center justify-between"
288
339
  >
289
340
  <span className="flex items-center gap-2 shrink-0">
290
- <code className="text-accent-text">{port.port}</code>
341
+ <code className="inline-code">{port.port}</code>
291
342
  <span className="text-theme-text-disabled">/{port.protocol || 'TCP'}</span>
292
343
  </span>
293
344
  {port.name && (
@@ -329,11 +380,15 @@ export function PortForwardInlineButton({
329
380
  protocol = 'TCP',
330
381
  disabled = false,
331
382
  }: PortForwardInlineButtonProps) {
332
- const { data: clusterInfo } = useClusterInfo()
383
+ const isLocal = useIsLocalDeployment()
333
384
  const startPortForward = useStartPortForward()
334
385
  const [dialogInfo, setDialogInfo] = useState<KubectlDialogInfo | null>(null)
335
386
 
336
- 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
337
392
  const isPending = !inCluster && startPortForward.isPending
338
393
 
339
394
  const handleClick = (e: React.MouseEvent) => {
@@ -352,21 +407,37 @@ export function PortForwardInlineButton({
352
407
  }
353
408
  }
354
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
+
355
423
  return (
356
424
  <>
425
+ <Tooltip content={inCluster ? 'Copy a kubectl port-forward command' : `Port forward ${port}`}>
357
426
  <button
358
427
  onClick={handleClick}
359
428
  disabled={disabled || isPending}
360
- 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"
361
- title={`Port forward ${port}`}
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"
362
430
  >
363
- {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 ? '…' : ''}
364
434
  {isPending ? (
365
435
  <Loader2 className="w-3 h-3 animate-spin" />
366
436
  ) : (
367
437
  <Plug className="w-3 h-3" />
368
438
  )}
369
439
  </button>
440
+ </Tooltip>
370
441
  {dialogInfo && (
371
442
  <KubectlCommandDialog info={dialogInfo} onClose={() => setDialogInfo(null)} />
372
443
  )}