@skyhook-io/radar-app 0.2.2 → 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 (177) 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/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -20,6 +20,7 @@ import {
20
20
  Globe,
21
21
  Monitor,
22
22
  PenLine,
23
+ RotateCw,
23
24
  } from 'lucide-react'
24
25
  import { clsx } from 'clsx'
25
26
  // CSS_EASE (the shared spring curve) is intentionally NOT used for this panel —
@@ -30,6 +31,7 @@ import { Tooltip } from '../ui/Tooltip'
30
31
  import { useToast } from '../ui/Toast'
31
32
  import { openExternal } from '../../utils/navigation'
32
33
  import { apiUrl } from '../../api/config'
34
+ import { apiFetch, useCapabilities } from '../../api/client'
33
35
  import { pluralize } from '@skyhook-io/k8s-ui'
34
36
 
35
37
  // --- Types -------------------------------------------------------------------
@@ -42,21 +44,58 @@ interface PortForwardSession {
42
44
  localPort: number
43
45
  listenAddress: string
44
46
  serviceName?: string
47
+ servicePort?: number
48
+ scheme?: 'http' | 'https'
45
49
  startedAt: string
46
50
  status: 'running' | 'stopped' | 'error'
47
51
  error?: string
48
52
  }
49
53
 
54
+ function sessionUrl(session: PortForwardSession): string {
55
+ return `${session.scheme || 'http'}://localhost:${session.localPort}`
56
+ }
57
+
58
+ function formatPortLabel(session: PortForwardSession): string {
59
+ if (session.servicePort && session.servicePort !== session.podPort) {
60
+ return `${session.servicePort} → ${session.podPort}`
61
+ }
62
+ return String(session.podPort)
63
+ }
64
+
65
+ // Build the recreate request body for toggle-listen / change-port flows.
66
+ // When the original session was service-resolved, the recreate must also go
67
+ // through the service path (servicePort + serviceName). Sending podName+podPort
68
+ // would skip resolution and validate against the pod's declared containerPorts,
69
+ // which can fail even though the original session worked: services can route to
70
+ // any port the container actually listens on, regardless of containerPort
71
+ // declarations. Going through service also re-resolves to a currently-running
72
+ // pod if the original has since been replaced.
73
+ function buildRecreateBody(session: PortForwardSession, overrides: { localPort: number; listenAddress: string }) {
74
+ const base = {
75
+ namespace: session.namespace,
76
+ localPort: overrides.localPort,
77
+ listenAddress: overrides.listenAddress,
78
+ }
79
+ if (session.serviceName && session.servicePort) {
80
+ return { ...base, serviceName: session.serviceName, podPort: session.servicePort }
81
+ }
82
+ return { ...base, podName: session.podName, podPort: session.podPort }
83
+ }
84
+
50
85
  // --- Shared query ------------------------------------------------------------
51
86
 
52
- function usePortForwardQuery() {
87
+ function usePortForwardQuery(enabled: boolean) {
53
88
  return useQuery<PortForwardSession[]>({
54
89
  queryKey: ['portforwards'],
55
90
  queryFn: async () => {
56
- const res = await fetch(apiUrl('/portforwards'))
91
+ const res = await apiFetch(apiUrl('/portforwards'))
57
92
  if (!res.ok) throw new Error('Failed to fetch port forwards')
58
93
  return res.json()
59
94
  },
95
+ // Port-forward is a local-binary feature; in-cluster (Radar Cloud) the
96
+ // capability is false, so don't poll an endpoint that can never return a
97
+ // usable session. Also covers RBAC-denied users.
98
+ enabled,
60
99
  // 30s fallback poll — user mutations invalidate immediately, but out-of-band
61
100
  // session death (pod restart, OOM kill, server-side cleanup) only surfaces on
62
101
  // the next tick.
@@ -109,12 +148,21 @@ interface PortForwardContextValue {
109
148
  const PortForwardContext = createContext<PortForwardContextValue | null>(null)
110
149
 
111
150
  export function PortForwardProvider({ children }: { children: ReactNode }) {
151
+ // Gate the session-list poll on runtime mode, not the RBAC capability: port-forward
152
+ // only works when radar runs as a local binary, so in-cluster (Radar Cloud) we never
153
+ // poll /portforwards. We deliberately do NOT gate on `portForward` (RBAC) — a local
154
+ // user with portforward rights in only some namespaces must still see/stop the
155
+ // sessions they start (the start buttons gate per-namespace separately). Using the
156
+ // resolved value (undefined while capabilities load → no poll) keeps Cloud silent on
157
+ // first paint.
158
+ const { data: caps } = useCapabilities()
159
+ const canPortForward = caps?.deployment?.mode === 'local'
112
160
  const {
113
161
  data: sessions = [],
114
162
  isLoading,
115
163
  isError: isQueryError,
116
164
  error: queryError,
117
- } = usePortForwardQuery()
165
+ } = usePortForwardQuery(canPortForward)
118
166
  const activeSessions = sessions.filter((s) => s.status !== 'stopped')
119
167
  const errorSessions = sessions.filter((s) => s.status === 'error')
120
168
  const count = activeSessions.length
@@ -139,10 +187,25 @@ export function PortForwardProvider({ children }: { children: ReactNode }) {
139
187
  const measureAnchor = useCallback(() => {
140
188
  if (!indicatorRef.current) return
141
189
  const rect = indicatorRef.current.getBoundingClientRect()
190
+ // Align panel right edge with indicator right edge, but clamp so the
191
+ // panel's left edge never runs off the viewport's left edge — happens on
192
+ // narrow windows / split-screens where the indicator is closer to the
193
+ // left edge than the panel is wide. PANEL_WIDTH must match the panel's
194
+ // Tailwind w-80 (20rem = 320px).
195
+ const PANEL_WIDTH = 320
196
+ const MARGIN = 8
197
+ const desiredRight = Math.max(MARGIN, window.innerWidth - rect.right)
198
+ const maxRight = Math.max(MARGIN, window.innerWidth - PANEL_WIDTH - MARGIN)
199
+ const right = Math.min(desiredRight, maxRight)
200
+ // Keep the caret pointing at the indicator's horizontal center even after
201
+ // the panel has been clamped away from the indicator.
202
+ const panelRightX = window.innerWidth - right
203
+ const indicatorCenterX = rect.right - rect.width / 2
204
+ const caretRight = panelRightX - indicatorCenterX - 6
142
205
  setAnchor({
143
206
  top: rect.bottom + 10,
144
- right: Math.max(16, window.innerWidth - rect.right),
145
- caretRight: rect.width / 2 - 6,
207
+ right,
208
+ caretRight,
146
209
  })
147
210
  }, [])
148
211
 
@@ -342,6 +405,9 @@ export function PortForwardPanel() {
342
405
  // without disabling all stop buttons (the old shared-mutation approach blocked
343
406
  // every row when any single stop was in-flight).
344
407
  const [stoppingIds, setStoppingIds] = useState<Set<string>>(() => new Set())
408
+ // Per-session retry tracking — same rationale as stoppingIds: multiple failed
409
+ // forwards can be retried independently without disabling every retry button.
410
+ const [retryingIds, setRetryingIds] = useState<Set<string>>(() => new Set())
345
411
  const queryClient = useQueryClient()
346
412
  const { showSuccess, showError } = useToast()
347
413
 
@@ -376,7 +442,7 @@ export function PortForwardPanel() {
376
442
  const stopPortForward = useCallback(async (id: string) => {
377
443
  setStoppingIds(prev => new Set(prev).add(id))
378
444
  try {
379
- const res = await fetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
445
+ const res = await apiFetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
380
446
  if (!res.ok) {
381
447
  const body = await res.json().catch(() => ({}))
382
448
  throw new Error(body.error || `Failed to stop port forward (HTTP ${res.status})`)
@@ -396,6 +462,48 @@ export function PortForwardPanel() {
396
462
  }
397
463
  }, [queryClient, showError])
398
464
 
465
+ // Recreate a failed forward. The errored session is already dead — there's no live
466
+ // forward to lose — so we drop the stale row FIRST, then recreate. Delete-first keeps
467
+ // the panel at exactly one row in every outcome (success → one running row; failure →
468
+ // one errored row), avoiding the orphaned-duplicate the reverse order would leave when
469
+ // the backend keeps a failed-start session in its map. A 404 means it was already
470
+ // cleared (e.g. context switch) — benign, proceed. Service-resolved sessions re-route
471
+ // through the service path via buildRecreateBody, so a retry after the backing pod was
472
+ // replaced re-resolves to a currently-running pod.
473
+ const retryPortForward = useCallback(async (session: PortForwardSession) => {
474
+ commitInteraction()
475
+ setRetryingIds(prev => new Set(prev).add(session.id))
476
+ try {
477
+ const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
478
+ if (!delRes.ok && delRes.status !== 404) {
479
+ const body = await delRes.json().catch(() => ({}))
480
+ throw new Error(body.error || `Failed to clear failed port forward (HTTP ${delRes.status})`)
481
+ }
482
+ const res = await apiFetch(apiUrl('/portforwards'), {
483
+ method: 'POST',
484
+ headers: { 'Content-Type': 'application/json' },
485
+ body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: session.listenAddress })),
486
+ })
487
+ if (!res.ok) {
488
+ const body = await res.json().catch(() => ({}))
489
+ throw new Error(body.error || `Failed to retry port forward (HTTP ${res.status})`)
490
+ }
491
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
492
+ showSuccess('Port forward restarted', `Now listening on localhost:${session.localPort}`)
493
+ } catch (err) {
494
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
495
+ const msg = err instanceof Error ? err.message : 'Failed to retry port forward'
496
+ showError('Failed to retry port forward', msg)
497
+ console.error('Failed to retry port forward:', err)
498
+ } finally {
499
+ setRetryingIds(prev => {
500
+ const next = new Set(prev)
501
+ next.delete(session.id)
502
+ return next
503
+ })
504
+ }
505
+ }, [commitInteraction, queryClient, showSuccess, showError])
506
+
399
507
  const toggleListenAddress = async (session: PortForwardSession) => {
400
508
  commitInteraction()
401
509
  const newAddress = session.listenAddress === '0.0.0.0' ? '127.0.0.1' : '0.0.0.0'
@@ -406,23 +514,16 @@ export function PortForwardPanel() {
406
514
  // apart from "original gone and recreate failed = data loss."
407
515
  let deleted = false
408
516
  try {
409
- const delRes = await fetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
517
+ const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
410
518
  if (!delRes.ok) {
411
519
  const body = await delRes.json().catch(() => ({}))
412
520
  throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
413
521
  }
414
522
  deleted = true
415
- const res = await fetch(apiUrl('/portforwards'), {
523
+ const res = await apiFetch(apiUrl('/portforwards'), {
416
524
  method: 'POST',
417
525
  headers: { 'Content-Type': 'application/json' },
418
- body: JSON.stringify({
419
- namespace: session.namespace,
420
- podName: session.podName || undefined,
421
- serviceName: session.serviceName || undefined,
422
- podPort: session.podPort,
423
- localPort: session.localPort,
424
- listenAddress: newAddress,
425
- }),
526
+ body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
426
527
  })
427
528
  if (!res.ok) {
428
529
  const error = await res.json().catch(() => ({}))
@@ -460,23 +561,16 @@ export function PortForwardPanel() {
460
561
  // apart from "original gone and recreate failed = data loss."
461
562
  let deleted = false
462
563
  try {
463
- const delRes = await fetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
564
+ const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
464
565
  if (!delRes.ok) {
465
566
  const body = await delRes.json().catch(() => ({}))
466
567
  throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
467
568
  }
468
569
  deleted = true
469
- const res = await fetch(apiUrl('/portforwards'), {
570
+ const res = await apiFetch(apiUrl('/portforwards'), {
470
571
  method: 'POST',
471
572
  headers: { 'Content-Type': 'application/json' },
472
- body: JSON.stringify({
473
- namespace: session.namespace,
474
- podName: session.podName || undefined,
475
- serviceName: session.serviceName || undefined,
476
- podPort: session.podPort,
477
- localPort: newPort,
478
- listenAddress: session.listenAddress,
479
- }),
573
+ body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
480
574
  })
481
575
  if (!res.ok) {
482
576
  const error = await res.json().catch(() => ({}))
@@ -506,7 +600,7 @@ export function PortForwardPanel() {
506
600
  async (session: PortForwardSession) => {
507
601
  commitInteraction()
508
602
  try {
509
- await navigator.clipboard.writeText(`http://localhost:${session.localPort}`)
603
+ await navigator.clipboard.writeText(sessionUrl(session))
510
604
  } catch (err) {
511
605
  // Clipboard API can reject in non-secure contexts, denied permissions, or
512
606
  // when the document isn't focused. Surface the failure — the checkmark
@@ -529,7 +623,7 @@ export function PortForwardPanel() {
529
623
  const handleOpenUrl = useCallback(
530
624
  (session: PortForwardSession) => {
531
625
  commitInteraction()
532
- openExternal(`http://localhost:${session.localPort}`)
626
+ openExternal(sessionUrl(session))
533
627
  },
534
628
  [commitInteraction]
535
629
  )
@@ -550,7 +644,7 @@ export function PortForwardPanel() {
550
644
  onMouseEnter={onPanelHoverEnter}
551
645
  onMouseLeave={onPanelHoverLeave}
552
646
  className={clsx(
553
- 'fixed z-[51] w-80',
647
+ 'fixed z-[51] w-[26rem] max-w-[calc(100vw-2rem)]',
554
648
  'transition-opacity duration-150 ease-out',
555
649
  isPanelOpen
556
650
  ? 'opacity-100 pointer-events-auto'
@@ -611,7 +705,7 @@ export function PortForwardPanel() {
611
705
  </div>
612
706
 
613
707
  {/* Sessions list */}
614
- <div className="max-h-64 overflow-y-auto">
708
+ <div className="max-h-[28rem] overflow-y-auto">
615
709
  {isQueryError ? (
616
710
  <div className="p-3 text-xs bg-red-500/10 border-b border-theme-border">
617
711
  <div className={clsx('badge-sm mb-1 inline-block', SEVERITY_BADGE.error)}>
@@ -632,41 +726,107 @@ export function PortForwardPanel() {
632
726
  </div>
633
727
  ) : (
634
728
  <div className="divide-y divide-theme-border">
635
- {activeSessions.map((session) => (
636
- <div
637
- key={session.id}
638
- className={clsx(
639
- 'p-3',
640
- session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
641
- )}
642
- >
729
+ {activeSessions.map((session) => {
730
+ const resourceName = session.serviceName || session.podName
731
+ return (
732
+ <div
733
+ key={session.id}
734
+ className={clsx(
735
+ 'p-3 space-y-1',
736
+ session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
737
+ )}
738
+ >
739
+ {/* Row 1: status dot + name | stop button */}
643
740
  <div className="flex items-start justify-between gap-2">
644
- <div className="flex-1 min-w-0">
645
- <div className="flex items-center gap-2">
646
- <span
647
- className={clsx(
648
- 'w-2 h-2 rounded-full shrink-0',
649
- session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
650
- )}
651
- />
652
- <span className="text-sm text-theme-text-primary font-medium truncate">
653
- {session.serviceName || session.podName}
654
- </span>
655
- {session.status === 'error' && (
656
- <span className={clsx('badge-sm', SEVERITY_BADGE.error)}>Failed</span>
741
+ <div className="flex items-start gap-2 min-w-0 flex-1">
742
+ <span
743
+ className={clsx(
744
+ 'w-2 h-2 rounded-full shrink-0 mt-[7px]',
745
+ session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
657
746
  )}
658
- </div>
659
- <div className="mt-1 text-xs text-theme-text-disabled">
660
- {session.namespace} · Port {session.podPort}
661
- </div>
662
- {session.status === 'error' && session.error && (
663
- <div className="mt-1.5 text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
664
- {session.error}
665
- </div>
747
+ />
748
+ <Tooltip content={resourceName} delay={300} position="bottom" disabled={!isPanelOpen} wrapperClassName="min-w-0 flex-1">
749
+ <span className="block min-w-0 truncate text-sm font-medium text-theme-text-primary">
750
+ {resourceName}
751
+ </span>
752
+ </Tooltip>
753
+ {session.status === 'error' && (
754
+ <span className={clsx('badge-sm shrink-0', SEVERITY_BADGE.error)}>Failed</span>
666
755
  )}
756
+ </div>
757
+ <div className="flex items-center gap-0.5 shrink-0">
667
758
  {session.status === 'running' && (
668
- <div className="mt-1.5 flex items-center gap-2">
669
- {editingPortId === session.id ? (
759
+ <Tooltip
760
+ content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
761
+ delay={300} position="bottom" disabled={!isPanelOpen}
762
+ >
763
+ <button
764
+ onClick={() => toggleListenAddress(session)}
765
+ disabled={togglingId === session.id || changingPortId === session.id}
766
+ className={clsx(
767
+ 'flex items-center justify-center p-1.5 rounded transition-colors',
768
+ session.listenAddress === '0.0.0.0'
769
+ ? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
770
+ : 'text-theme-text-disabled hover:text-theme-text-primary hover:bg-theme-hover'
771
+ )}
772
+ >
773
+ {togglingId === session.id ? (
774
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
775
+ ) : session.listenAddress === '0.0.0.0' ? (
776
+ <Globe className="w-3.5 h-3.5" />
777
+ ) : (
778
+ <Monitor className="w-3.5 h-3.5" />
779
+ )}
780
+ </button>
781
+ </Tooltip>
782
+ )}
783
+ {session.status === 'error' && (
784
+ <Tooltip content="Retry" delay={300} position="bottom" disabled={!isPanelOpen}>
785
+ <button
786
+ onClick={() => retryPortForward(session)}
787
+ disabled={retryingIds.has(session.id) || stoppingIds.has(session.id)}
788
+ className="p-1.5 text-theme-text-tertiary hover:text-green-400 hover:bg-theme-hover rounded disabled:opacity-50"
789
+ >
790
+ {retryingIds.has(session.id) ? (
791
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
792
+ ) : (
793
+ <RotateCw className="w-3.5 h-3.5" />
794
+ )}
795
+ </button>
796
+ </Tooltip>
797
+ )}
798
+ <Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
799
+ <button
800
+ onClick={() => {
801
+ commitInteraction()
802
+ stopPortForward(session.id)
803
+ }}
804
+ disabled={stoppingIds.has(session.id) || retryingIds.has(session.id)}
805
+ className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
806
+ >
807
+ <Trash2 className="w-3.5 h-3.5" />
808
+ </button>
809
+ </Tooltip>
810
+ </div>
811
+ </div>
812
+
813
+ {/* Row 2: namespace · port translation */}
814
+ <div className="text-xs text-theme-text-disabled">
815
+ {session.namespace} · {formatPortLabel(session)}
816
+ </div>
817
+
818
+ {/* Row 2.5: error message */}
819
+ {session.status === 'error' && session.error && (
820
+ <div className="text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
821
+ {session.error}
822
+ </div>
823
+ )}
824
+
825
+ {/* Row 3: URL (+ optional toggle) | copy + open */}
826
+ {session.status === 'running' && (
827
+ <div className="pt-0.5 flex items-center justify-between gap-2">
828
+ <div className="flex items-center gap-1.5 min-w-0">
829
+ {editingPortId === session.id ? (
670
830
  <div className="flex items-center text-xs bg-theme-base rounded text-accent-text font-mono">
671
831
  <span className="pl-2 py-1 text-theme-text-disabled select-none">
672
832
  {session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
@@ -709,10 +869,11 @@ export function PortForwardPanel() {
709
869
  />
710
870
  </div>
711
871
  ) : (
872
+ <>
712
873
  <Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
713
874
  <code
714
875
  className={clsx(
715
- 'group/port text-xs bg-theme-base px-2 py-1 rounded text-accent-text transition-all inline-flex items-center gap-1',
876
+ 'inline-code group/port text-xs transition-all inline-flex items-center gap-1',
716
877
  changingPortId === session.id
717
878
  ? 'opacity-50'
718
879
  : 'cursor-pointer hover:ring-1 hover:ring-blue-500/50'
@@ -732,76 +893,36 @@ export function PortForwardPanel() {
732
893
  <PenLine className="w-3 h-3 text-theme-text-disabled opacity-0 group-hover/port:opacity-100 transition-opacity" />
733
894
  </code>
734
895
  </Tooltip>
896
+ </>
897
+ )}
898
+ </div>
899
+ <div className="flex items-center gap-0.5 shrink-0">
900
+ <Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
901
+ <button
902
+ onClick={() => handleCopyUrl(session)}
903
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
904
+ >
905
+ {copiedId === session.id ? (
906
+ <Check className="w-3.5 h-3.5 text-green-400" />
907
+ ) : (
908
+ <Copy className="w-3.5 h-3.5" />
735
909
  )}
736
- <Tooltip
737
- content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
738
- delay={300} position="bottom" disabled={!isPanelOpen}
739
- >
740
- <button
741
- onClick={() => toggleListenAddress(session)}
742
- disabled={togglingId === session.id || changingPortId === session.id}
743
- className={clsx(
744
- 'flex items-center gap-1 px-1.5 py-0.5 text-xs rounded transition-colors',
745
- session.listenAddress === '0.0.0.0'
746
- ? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
747
- : 'bg-theme-elevated text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary'
748
- )}
749
- >
750
- {togglingId === session.id ? (
751
- <Loader2 className="w-3 h-3 animate-spin" />
752
- ) : session.listenAddress === '0.0.0.0' ? (
753
- <Globe className="w-3 h-3" />
754
- ) : (
755
- <Monitor className="w-3 h-3" />
756
- )}
757
- {session.listenAddress === '0.0.0.0' ? 'network' : 'local'}
758
- </button>
759
- </Tooltip>
760
- </div>
761
- )}
762
- </div>
763
-
764
- <div className="flex items-center gap-1 shrink-0">
765
- {session.status === 'running' && (
766
- <>
767
- <Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
768
- <button
769
- onClick={() => handleCopyUrl(session)}
770
- className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
771
- >
772
- {copiedId === session.id ? (
773
- <Check className="w-3.5 h-3.5 text-green-400" />
774
- ) : (
775
- <Copy className="w-3.5 h-3.5" />
776
- )}
777
- </button>
778
- </Tooltip>
779
- <Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
780
- <button
781
- onClick={() => handleOpenUrl(session)}
782
- className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
783
- >
784
- <ExternalLink className="w-3.5 h-3.5" />
785
- </button>
786
- </Tooltip>
787
- </>
788
- )}
789
- <Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
790
- <button
791
- onClick={() => {
792
- commitInteraction()
793
- stopPortForward(session.id)
794
- }}
795
- disabled={stoppingIds.has(session.id)}
796
- className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
797
- >
798
- <Trash2 className="w-3.5 h-3.5" />
799
- </button>
800
- </Tooltip>
910
+ </button>
911
+ </Tooltip>
912
+ <Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
913
+ <button
914
+ onClick={() => handleOpenUrl(session)}
915
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
916
+ >
917
+ <ExternalLink className="w-3.5 h-3.5" />
918
+ </button>
919
+ </Tooltip>
920
+ </div>
801
921
  </div>
922
+ )}
802
923
  </div>
803
- </div>
804
- ))}
924
+ )
925
+ })}
805
926
  </div>
806
927
  )}
807
928
  </div>
@@ -841,7 +962,7 @@ export function useStartPortForward() {
841
962
  localPort?: number
842
963
  listenAddress?: string // "127.0.0.1" (default) or "0.0.0.0"
843
964
  }) => {
844
- const res = await fetch(apiUrl('/portforwards'), {
965
+ const res = await apiFetch(apiUrl('/portforwards'), {
845
966
  method: 'POST',
846
967
  headers: { 'Content-Type': 'application/json' },
847
968
  body: JSON.stringify(req),
@@ -867,6 +988,7 @@ export function useStartPortForward() {
867
988
 
868
989
  // Backwards-compat: existing consumers that just want a count number.
869
990
  export function usePortForwardCount() {
870
- const { data: sessions = [] } = usePortForwardQuery()
991
+ const { data: caps } = useCapabilities()
992
+ const { data: sessions = [] } = usePortForwardQuery(caps?.deployment?.mode === 'local')
871
993
  return sessions.filter((s) => s.status !== 'stopped').length
872
994
  }