@skyhook-io/radar-app 0.1.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 (251) hide show
  1. package/README.md +67 -0
  2. package/package.json +80 -0
  3. package/src/App.tsx +1538 -0
  4. package/src/RadarApp.tsx +145 -0
  5. package/src/api/apiResources.ts +28 -0
  6. package/src/api/client.ts +2583 -0
  7. package/src/api/config.ts +116 -0
  8. package/src/api/traffic.ts +139 -0
  9. package/src/components/ConnectionErrorView.tsx +272 -0
  10. package/src/components/ContextSwitcher.tsx +481 -0
  11. package/src/components/DebugOverlay.tsx +94 -0
  12. package/src/components/UserMenu.tsx +87 -0
  13. package/src/components/audit/AuditSettingsDialog.tsx +162 -0
  14. package/src/components/audit/AuditView.tsx +123 -0
  15. package/src/components/cost/CostTrendChart.tsx +388 -0
  16. package/src/components/cost/CostView.tsx +545 -0
  17. package/src/components/dock/BottomDock.tsx +96 -0
  18. package/src/components/dock/DockContext.tsx +11 -0
  19. package/src/components/dock/LocalTerminalTab.tsx +22 -0
  20. package/src/components/dock/LogsTab.tsx +26 -0
  21. package/src/components/dock/NodeTerminalTab.tsx +50 -0
  22. package/src/components/dock/TerminalTab.tsx +42 -0
  23. package/src/components/dock/TrafficFlowListTab.tsx +18 -0
  24. package/src/components/dock/WorkloadLogsTab.tsx +23 -0
  25. package/src/components/dock/index.ts +2 -0
  26. package/src/components/gitops/GitOpsActions.tsx +1 -0
  27. package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
  28. package/src/components/gitops/ManagedResourcesList.tsx +1 -0
  29. package/src/components/gitops/SyncCountdown.tsx +1 -0
  30. package/src/components/gitops/index.ts +4 -0
  31. package/src/components/helm/ChartBrowser.tsx +580 -0
  32. package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
  33. package/src/components/helm/HelmView.tsx +475 -0
  34. package/src/components/helm/InstallWizard.tsx +1060 -0
  35. package/src/components/helm/ManifestDiffViewer.tsx +91 -0
  36. package/src/components/helm/ManifestViewer.tsx +61 -0
  37. package/src/components/helm/OwnedResources.tsx +465 -0
  38. package/src/components/helm/RevisionHistory.tsx +167 -0
  39. package/src/components/helm/ValuesDiffPreview.tsx +190 -0
  40. package/src/components/helm/ValuesViewer.tsx +365 -0
  41. package/src/components/helm/helm-utils.ts +37 -0
  42. package/src/components/home/ActivitySummary.tsx +262 -0
  43. package/src/components/home/CertificateHealthCard.tsx +105 -0
  44. package/src/components/home/ClusterHealthCard.tsx +483 -0
  45. package/src/components/home/CostCard.tsx +112 -0
  46. package/src/components/home/HealthRing.tsx +1 -0
  47. package/src/components/home/HelmSummary.tsx +129 -0
  48. package/src/components/home/HomeView.tsx +224 -0
  49. package/src/components/home/MCPSetupDialog.tsx +417 -0
  50. package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
  51. package/src/components/home/TopologyPreview.tsx +219 -0
  52. package/src/components/home/TrafficSummary.tsx +154 -0
  53. package/src/components/logs/JsonLogLine.tsx +1 -0
  54. package/src/components/logs/LogCore.tsx +2 -0
  55. package/src/components/logs/LogsViewer.tsx +44 -0
  56. package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
  57. package/src/components/logs/useLogBuffer.ts +2 -0
  58. package/src/components/logs/useLogSearch.ts +1 -0
  59. package/src/components/portforward/PortForwardButton.tsx +375 -0
  60. package/src/components/portforward/PortForwardManager.tsx +871 -0
  61. package/src/components/resource/PrometheusCharts.tsx +687 -0
  62. package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
  63. package/src/components/resources/ImageFilesystemModal.tsx +745 -0
  64. package/src/components/resources/PodFilesystemModal.tsx +407 -0
  65. package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
  66. package/src/components/resources/ResourcesView.tsx +190 -0
  67. package/src/components/resources/drawer-components.tsx +1 -0
  68. package/src/components/resources/file-browser-utils.ts +35 -0
  69. package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
  71. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
  72. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
  73. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
  74. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
  75. package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
  76. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
  77. package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
  78. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
  79. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
  80. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
  82. package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
  83. package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
  84. package/src/components/resources/renderers/EventRenderer.tsx +1 -0
  85. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
  86. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
  87. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
  88. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
  89. package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
  90. package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
  91. package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
  92. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
  93. package/src/components/resources/renderers/HPARenderer.tsx +1 -0
  94. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
  95. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
  96. package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
  97. package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
  98. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
  99. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
  100. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
  101. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
  102. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
  103. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
  104. package/src/components/resources/renderers/JobRenderer.tsx +1 -0
  105. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
  106. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
  107. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
  108. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
  109. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
  110. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
  111. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
  112. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
  113. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
  114. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
  116. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
  117. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
  118. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
  119. package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
  120. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
  121. package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
  122. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
  123. package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
  124. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
  125. package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
  126. package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
  127. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
  128. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
  129. package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
  130. package/src/components/resources/renderers/PodRenderer.tsx +94 -0
  131. package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
  132. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
  133. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
  134. package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
  135. package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
  136. package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
  137. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
  138. package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
  139. package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
  140. package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
  141. package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
  142. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
  143. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
  144. package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
  145. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
  146. package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
  147. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
  148. package/src/components/resources/renderers/VPARenderer.tsx +1 -0
  149. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
  150. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
  151. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
  152. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
  153. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
  154. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
  155. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
  156. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
  157. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
  158. package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
  159. package/src/components/resources/renderers/argo-cells.tsx +1 -0
  160. package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
  161. package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
  162. package/src/components/resources/renderers/eso-cells.tsx +1 -0
  163. package/src/components/resources/renderers/flux-cells.tsx +1 -0
  164. package/src/components/resources/renderers/index.ts +91 -0
  165. package/src/components/resources/renderers/istio-cells.tsx +1 -0
  166. package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
  167. package/src/components/resources/renderers/keda-cells.tsx +1 -0
  168. package/src/components/resources/renderers/knative-cells.tsx +1 -0
  169. package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
  170. package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
  171. package/src/components/resources/renderers/traefik-cells.tsx +1 -0
  172. package/src/components/resources/renderers/trivy-cells.tsx +1 -0
  173. package/src/components/resources/renderers/trivy-shared.tsx +1 -0
  174. package/src/components/resources/renderers/velero-cells.tsx +1 -0
  175. package/src/components/resources/resource-utils-argo.ts +2 -0
  176. package/src/components/resources/resource-utils-certmanager.ts +2 -0
  177. package/src/components/resources/resource-utils-cnpg.ts +2 -0
  178. package/src/components/resources/resource-utils-eso.ts +2 -0
  179. package/src/components/resources/resource-utils-flux.ts +2 -0
  180. package/src/components/resources/resource-utils-istio.ts +2 -0
  181. package/src/components/resources/resource-utils-karpenter.ts +2 -0
  182. package/src/components/resources/resource-utils-keda.ts +2 -0
  183. package/src/components/resources/resource-utils-knative.ts +2 -0
  184. package/src/components/resources/resource-utils-kyverno.ts +2 -0
  185. package/src/components/resources/resource-utils-prometheus.ts +2 -0
  186. package/src/components/resources/resource-utils-traefik.ts +1 -0
  187. package/src/components/resources/resource-utils-trivy.ts +2 -0
  188. package/src/components/resources/resource-utils-velero.ts +2 -0
  189. package/src/components/resources/resource-utils.ts +5 -0
  190. package/src/components/settings/SettingsDialog.tsx +537 -0
  191. package/src/components/shared/CreateResourceDialog.tsx +17 -0
  192. package/src/components/shared/EditableYamlView.tsx +24 -0
  193. package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
  194. package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
  195. package/src/components/timeline/DiffViewer.tsx +1 -0
  196. package/src/components/timeline/TimelineList.tsx +69 -0
  197. package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
  198. package/src/components/timeline/TimelineView.tsx +157 -0
  199. package/src/components/timeline/shared.tsx +1 -0
  200. package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
  201. package/src/components/traffic/TrafficFlowList.tsx +415 -0
  202. package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
  203. package/src/components/traffic/TrafficGraph.tsx +1546 -0
  204. package/src/components/traffic/TrafficView.tsx +1213 -0
  205. package/src/components/traffic/TrafficWizard.tsx +386 -0
  206. package/src/components/traffic/index.ts +3 -0
  207. package/src/components/ui/CodeViewer.tsx +8 -0
  208. package/src/components/ui/CommandPalette.tsx +460 -0
  209. package/src/components/ui/ConfirmDialog.tsx +1 -0
  210. package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
  211. package/src/components/ui/ErrorBoundary.tsx +46 -0
  212. package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
  213. package/src/components/ui/Markdown.tsx +108 -0
  214. package/src/components/ui/MetricsChart.tsx +1 -0
  215. package/src/components/ui/NamespaceSelector.tsx +436 -0
  216. package/src/components/ui/ResourceBar.tsx +1 -0
  217. package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
  218. package/src/components/ui/Toast.tsx +1 -0
  219. package/src/components/ui/Tooltip.tsx +1 -0
  220. package/src/components/ui/UpdateNotification.tsx +299 -0
  221. package/src/components/ui/YamlEditor.tsx +1 -0
  222. package/src/components/workload/WorkloadView.tsx +532 -0
  223. package/src/context/ConnectionContext.tsx +173 -0
  224. package/src/context/ContextSwitchContext.tsx +56 -0
  225. package/src/context/NavCustomization.tsx +62 -0
  226. package/src/context/ThemeContext.tsx +97 -0
  227. package/src/contexts/CapabilitiesContext.tsx +130 -0
  228. package/src/hooks/useAnimatedUnmount.ts +1 -0
  229. package/src/hooks/useDesktopDownload.ts +41 -0
  230. package/src/hooks/useEventSource.ts +262 -0
  231. package/src/hooks/useFavorites.ts +69 -0
  232. package/src/hooks/useKeyboardShortcuts.tsx +7 -0
  233. package/src/hooks/useRefreshAnimation.ts +1 -0
  234. package/src/index.css +243 -0
  235. package/src/index.ts +17 -0
  236. package/src/main.tsx +158 -0
  237. package/src/types/gitops.ts +2 -0
  238. package/src/types.ts +3 -0
  239. package/src/utils/animation.ts +2 -0
  240. package/src/utils/badge-colors.ts +2 -0
  241. package/src/utils/context-name.ts +2 -0
  242. package/src/utils/desktop-download.ts +66 -0
  243. package/src/utils/desktop-open-folder.ts +21 -0
  244. package/src/utils/format.ts +2 -0
  245. package/src/utils/log-format.ts +12 -0
  246. package/src/utils/navigation.ts +23 -0
  247. package/src/utils/resource-hierarchy.ts +2 -0
  248. package/src/utils/resource-icons.ts +2 -0
  249. package/src/utils/skeleton-yaml.ts +2 -0
  250. package/src/utils/traffic-colors.ts +54 -0
  251. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,871 @@
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useRef,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ createContext,
8
+ useContext,
9
+ type ReactNode,
10
+ } from 'react'
11
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
12
+ import {
13
+ ExternalLink,
14
+ Copy,
15
+ Check,
16
+ Trash2,
17
+ Loader2,
18
+ ChevronUp,
19
+ Plug,
20
+ Globe,
21
+ Monitor,
22
+ PenLine,
23
+ } from 'lucide-react'
24
+ import { clsx } from 'clsx'
25
+ // CSS_EASE (the shared spring curve) is intentionally NOT used for this panel —
26
+ // its overshoot makes scale animations on small popovers look bouncy.
27
+ // We use a custom ease-out inline instead.
28
+ import { SEVERITY_BADGE } from '../../utils/badge-colors'
29
+ import { Tooltip } from '../ui/Tooltip'
30
+ import { useToast } from '../ui/Toast'
31
+ import { openExternal } from '../../utils/navigation'
32
+ import { apiUrl } from '../../api/config'
33
+
34
+ // --- Types -------------------------------------------------------------------
35
+
36
+ interface PortForwardSession {
37
+ id: string
38
+ namespace: string
39
+ podName: string
40
+ podPort: number
41
+ localPort: number
42
+ listenAddress: string
43
+ serviceName?: string
44
+ startedAt: string
45
+ status: 'running' | 'stopped' | 'error'
46
+ error?: string
47
+ }
48
+
49
+ // --- Shared query ------------------------------------------------------------
50
+
51
+ function usePortForwardQuery() {
52
+ return useQuery<PortForwardSession[]>({
53
+ queryKey: ['portforwards'],
54
+ queryFn: async () => {
55
+ const res = await fetch(apiUrl('/portforwards'))
56
+ if (!res.ok) throw new Error('Failed to fetch port forwards')
57
+ return res.json()
58
+ },
59
+ // 30s fallback poll — user mutations invalidate immediately, but out-of-band
60
+ // session death (pod restart, OOM kill, server-side cleanup) only surfaces on
61
+ // the next tick.
62
+ refetchInterval: 30000,
63
+ })
64
+ }
65
+
66
+ // --- Context & provider ------------------------------------------------------
67
+
68
+ // Show the panel this long after a new forward starts before auto-minimizing.
69
+ const AUTO_MINIMIZE_INITIAL_MS = 4000
70
+ // Shorter grace period after the cursor leaves a hovered panel.
71
+ const AUTO_MINIMIZE_HOVER_LEAVE_MS = 1500
72
+
73
+ /** Measured position for anchoring the panel to the indicator button. */
74
+ interface PanelAnchor {
75
+ top: number
76
+ right: number
77
+ /** Horizontal center of the indicator (relative to panel's right edge) — for caret positioning. */
78
+ caretRight: number
79
+ }
80
+
81
+ interface PortForwardContextValue {
82
+ sessions: PortForwardSession[]
83
+ activeSessions: PortForwardSession[]
84
+ errorSessions: PortForwardSession[]
85
+ isLoading: boolean
86
+ /** True when the session query itself failed (network/server error). */
87
+ isQueryError: boolean
88
+ queryError: Error | null
89
+ isPanelOpen: boolean
90
+ openPanel: () => void
91
+ minimizePanel: () => void
92
+ togglePanel: () => void
93
+ /**
94
+ * Permanently disarms the auto-minimize timer. Call from any user-initiated
95
+ * interaction inside the panel. Re-arming only happens when a new forward
96
+ * starts AND the panel is currently closed (minimized or never opened) —
97
+ * an already-open panel stays sticky regardless of count changes.
98
+ */
99
+ commitInteraction: () => void
100
+ onPanelHoverEnter: () => void
101
+ onPanelHoverLeave: () => void
102
+ /** Ref for the indicator button — used for dynamic panel positioning. */
103
+ indicatorRef: React.RefObject<HTMLButtonElement | null>
104
+ /** Measured anchor position from the indicator, or null if not yet measured. */
105
+ anchor: PanelAnchor | null
106
+ }
107
+
108
+ const PortForwardContext = createContext<PortForwardContextValue | null>(null)
109
+
110
+ export function PortForwardProvider({ children }: { children: ReactNode }) {
111
+ const {
112
+ data: sessions = [],
113
+ isLoading,
114
+ isError: isQueryError,
115
+ error: queryError,
116
+ } = usePortForwardQuery()
117
+ const activeSessions = sessions.filter((s) => s.status !== 'stopped')
118
+ const errorSessions = sessions.filter((s) => s.status === 'error')
119
+ const count = activeSessions.length
120
+
121
+ const [isPanelOpen, setIsPanelOpen] = useState(false)
122
+ // Mirror isPanelOpen in a ref so the count-watch effect can read the *current* open state
123
+ // without needing isPanelOpen in its deps (which would re-run the effect on every open/close).
124
+ const isPanelOpenRef = useRef(false)
125
+ useEffect(() => { isPanelOpenRef.current = isPanelOpen }, [isPanelOpen])
126
+ // Armed = a new session opened the panel and we still intend to auto-minimize.
127
+ // Cleared by any user interaction (commitInteraction) or when the timer fires.
128
+ const autoMinimizeArmedRef = useRef(false)
129
+ const autoMinimizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
130
+ const isHoveringRef = useRef(false)
131
+ const prevCountRef = useRef(0)
132
+
133
+ // --- Indicator ref + panel anchor measurement ---
134
+ // The panel is positioned dynamically below the indicator button.
135
+ const indicatorRef = useRef<HTMLButtonElement>(null)
136
+ const [anchor, setAnchor] = useState<PanelAnchor | null>(null)
137
+
138
+ const measureAnchor = useCallback(() => {
139
+ if (!indicatorRef.current) return
140
+ const rect = indicatorRef.current.getBoundingClientRect()
141
+ setAnchor({
142
+ top: rect.bottom + 10,
143
+ right: Math.max(16, window.innerWidth - rect.right),
144
+ caretRight: rect.width / 2 - 6,
145
+ })
146
+ }, [])
147
+
148
+ // Measure on mount / count change (indicator may appear/disappear) + window resize.
149
+ useLayoutEffect(() => { measureAnchor() }, [count, measureAnchor])
150
+ useEffect(() => {
151
+ window.addEventListener('resize', measureAnchor)
152
+ return () => window.removeEventListener('resize', measureAnchor)
153
+ }, [measureAnchor])
154
+
155
+ const clearAutoMinimizeTimer = useCallback(() => {
156
+ if (autoMinimizeTimerRef.current) {
157
+ clearTimeout(autoMinimizeTimerRef.current)
158
+ autoMinimizeTimerRef.current = null
159
+ }
160
+ }, [])
161
+
162
+ const scheduleAutoMinimize = useCallback(
163
+ (delay: number) => {
164
+ clearAutoMinimizeTimer()
165
+ autoMinimizeTimerRef.current = setTimeout(() => {
166
+ setIsPanelOpen(false)
167
+ autoMinimizeArmedRef.current = false
168
+ autoMinimizeTimerRef.current = null
169
+ }, delay)
170
+ },
171
+ [clearAutoMinimizeTimer]
172
+ )
173
+
174
+ // Auto-open the panel when a new forward starts; fully close when all forwards stop.
175
+ // Important: if the panel was already open (manually or from an earlier auto-open that the
176
+ // user already committed to via interaction), don't re-arm the auto-minimize timer —
177
+ // that would close a panel the user deliberately kept visible.
178
+ useEffect(() => {
179
+ const prev = prevCountRef.current
180
+ prevCountRef.current = count
181
+ if (count > prev && count > 0) {
182
+ const wasClosed = !isPanelOpenRef.current
183
+ setIsPanelOpen(true)
184
+ if (wasClosed) {
185
+ autoMinimizeArmedRef.current = true
186
+ if (!isHoveringRef.current) {
187
+ scheduleAutoMinimize(AUTO_MINIMIZE_INITIAL_MS)
188
+ }
189
+ }
190
+ // If the panel was already open, leave armed state alone: a user who opened it
191
+ // manually stays sticky; a user who's still within their initial grace window
192
+ // keeps the existing timer.
193
+ } else if (count === 0) {
194
+ // Provider stays mounted across sessions — explicitly reset state so a future
195
+ // forward starts with a fresh auto-open + auto-minimize cycle. (The indicator
196
+ // and panel early-return when count===0 but they don't own this state.)
197
+ setIsPanelOpen(false)
198
+ autoMinimizeArmedRef.current = false
199
+ clearAutoMinimizeTimer()
200
+ }
201
+ }, [count, scheduleAutoMinimize, clearAutoMinimizeTimer])
202
+
203
+ // Cleanup any in-flight timer on unmount.
204
+ useEffect(() => () => clearAutoMinimizeTimer(), [clearAutoMinimizeTimer])
205
+
206
+ const openPanel = useCallback(() => {
207
+ setIsPanelOpen(true)
208
+ autoMinimizeArmedRef.current = false
209
+ clearAutoMinimizeTimer()
210
+ }, [clearAutoMinimizeTimer])
211
+
212
+ const minimizePanel = useCallback(() => {
213
+ setIsPanelOpen(false)
214
+ autoMinimizeArmedRef.current = false
215
+ clearAutoMinimizeTimer()
216
+ }, [clearAutoMinimizeTimer])
217
+
218
+ const togglePanel = useCallback(() => {
219
+ if (isPanelOpen) minimizePanel()
220
+ else openPanel()
221
+ }, [isPanelOpen, openPanel, minimizePanel])
222
+
223
+ const commitInteraction = useCallback(() => {
224
+ autoMinimizeArmedRef.current = false
225
+ clearAutoMinimizeTimer()
226
+ }, [clearAutoMinimizeTimer])
227
+
228
+ const onPanelHoverEnter = useCallback(() => {
229
+ isHoveringRef.current = true
230
+ clearAutoMinimizeTimer()
231
+ }, [clearAutoMinimizeTimer])
232
+
233
+ const onPanelHoverLeave = useCallback(() => {
234
+ isHoveringRef.current = false
235
+ if (autoMinimizeArmedRef.current) {
236
+ scheduleAutoMinimize(AUTO_MINIMIZE_HOVER_LEAVE_MS)
237
+ }
238
+ }, [scheduleAutoMinimize])
239
+
240
+ return (
241
+ <PortForwardContext.Provider
242
+ value={{
243
+ sessions,
244
+ activeSessions,
245
+ errorSessions,
246
+ isLoading,
247
+ isQueryError,
248
+ queryError: queryError as Error | null,
249
+ isPanelOpen,
250
+ openPanel,
251
+ minimizePanel,
252
+ togglePanel,
253
+ commitInteraction,
254
+ onPanelHoverEnter,
255
+ onPanelHoverLeave,
256
+ indicatorRef,
257
+ anchor,
258
+ }}
259
+ >
260
+ {children}
261
+ </PortForwardContext.Provider>
262
+ )
263
+ }
264
+
265
+ function usePortForwardContext(): PortForwardContextValue {
266
+ const ctx = useContext(PortForwardContext)
267
+ if (!ctx) {
268
+ throw new Error('usePortForwardContext must be used inside <PortForwardProvider>')
269
+ }
270
+ return ctx
271
+ }
272
+
273
+ // --- Header indicator --------------------------------------------------------
274
+
275
+ export function PortForwardIndicator() {
276
+ const { activeSessions, errorSessions, isPanelOpen, togglePanel, indicatorRef } = usePortForwardContext()
277
+ const count = activeSessions.length
278
+ if (count === 0) return null
279
+
280
+ const hasErrors = errorSessions.length > 0
281
+ const tooltipText = hasErrors
282
+ ? `${count} port forward${count !== 1 ? 's' : ''} — ${errorSessions.length} failed`
283
+ : `${count} active port forward${count !== 1 ? 's' : ''}`
284
+
285
+ return (
286
+ <Tooltip content={tooltipText} delay={150} position="bottom" disabled={isPanelOpen}>
287
+ <button
288
+ ref={indicatorRef}
289
+ type="button"
290
+ onClick={togglePanel}
291
+ aria-label={tooltipText}
292
+ aria-expanded={isPanelOpen}
293
+ className={clsx(
294
+ 'relative flex items-center gap-1.5 h-7 px-2 ml-2 rounded-md text-xs transition-colors',
295
+ isPanelOpen
296
+ ? 'bg-theme-elevated text-theme-text-primary'
297
+ : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
298
+ )}
299
+ >
300
+ {/* The icon itself pulses when running (replaces the separate dot overlay).
301
+ Slightly larger than standard pill icons for visual weight as a status indicator. */}
302
+ <Plug className={clsx(
303
+ 'w-5 h-5',
304
+ hasErrors ? 'text-red-400' : 'text-green-400',
305
+ !isPanelOpen && !hasErrors && 'animate-pulse'
306
+ )} />
307
+ <span className="font-mono tabular-nums">{count}</span>
308
+ {!isPanelOpen && hasErrors && (
309
+ <span className={clsx('badge-sm', SEVERITY_BADGE.error)}>
310
+ {errorSessions.length}
311
+ </span>
312
+ )}
313
+ </button>
314
+ </Tooltip>
315
+ )
316
+ }
317
+
318
+ // --- Floating panel ----------------------------------------------------------
319
+
320
+ export function PortForwardPanel() {
321
+ const {
322
+ activeSessions,
323
+ errorSessions,
324
+ isLoading,
325
+ isQueryError,
326
+ queryError,
327
+ isPanelOpen,
328
+ minimizePanel,
329
+ commitInteraction,
330
+ onPanelHoverEnter,
331
+ onPanelHoverLeave,
332
+ anchor,
333
+ } = usePortForwardContext()
334
+
335
+ const [copiedId, setCopiedId] = useState<string | null>(null)
336
+ const [editingPortId, setEditingPortId] = useState<string | null>(null)
337
+ const [editPortValue, setEditPortValue] = useState('')
338
+ const [changingPortId, setChangingPortId] = useState<string | null>(null)
339
+ const [togglingId, setTogglingId] = useState<string | null>(null)
340
+ // Per-session stop tracking — allows stopping multiple forwards simultaneously
341
+ // without disabling all stop buttons (the old shared-mutation approach blocked
342
+ // every row when any single stop was in-flight).
343
+ const [stoppingIds, setStoppingIds] = useState<Set<string>>(() => new Set())
344
+ const queryClient = useQueryClient()
345
+ const { showSuccess, showError } = useToast()
346
+
347
+ // Track the "copied!" reset timeout so it can be cleared if the panel unmounts
348
+ // before the 2s window elapses (otherwise React warns about setState on unmount).
349
+ const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
350
+ useEffect(() => {
351
+ return () => {
352
+ if (copyResetTimerRef.current) {
353
+ clearTimeout(copyResetTimerRef.current)
354
+ }
355
+ }
356
+ }, [])
357
+
358
+ // Minimize on Escape when the panel is visible. Skip if focus is in an input —
359
+ // the inline port editor has its own Escape handler (exits edit mode), and
360
+ // upstream inputs (ResourcesSidebar search, etc.) should keep their own semantics.
361
+ useEffect(() => {
362
+ if (!isPanelOpen) return
363
+ const handler = (e: KeyboardEvent) => {
364
+ if (e.key !== 'Escape') return
365
+ const active = document.activeElement
366
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
367
+ return
368
+ }
369
+ minimizePanel()
370
+ }
371
+ window.addEventListener('keydown', handler)
372
+ return () => window.removeEventListener('keydown', handler)
373
+ }, [isPanelOpen, minimizePanel])
374
+
375
+ const stopPortForward = useCallback(async (id: string) => {
376
+ setStoppingIds(prev => new Set(prev).add(id))
377
+ try {
378
+ const res = await fetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
379
+ if (!res.ok) {
380
+ const body = await res.json().catch(() => ({}))
381
+ throw new Error(body.error || `Failed to stop port forward (HTTP ${res.status})`)
382
+ }
383
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
384
+ } catch (err) {
385
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
386
+ const msg = err instanceof Error ? err.message : 'Failed to stop port forward'
387
+ showError('Failed to stop port forward', msg)
388
+ console.error('Failed to stop port forward:', err)
389
+ } finally {
390
+ setStoppingIds(prev => {
391
+ const next = new Set(prev)
392
+ next.delete(id)
393
+ return next
394
+ })
395
+ }
396
+ }, [queryClient, showError])
397
+
398
+ const toggleListenAddress = async (session: PortForwardSession) => {
399
+ commitInteraction()
400
+ const newAddress = session.listenAddress === '0.0.0.0' ? '127.0.0.1' : '0.0.0.0'
401
+ const prevLabel = session.listenAddress === '0.0.0.0' ? 'network' : 'localhost'
402
+ const nextLabel = newAddress === '0.0.0.0' ? 'network' : 'localhost'
403
+ setTogglingId(session.id)
404
+ // Track whether the DELETE half succeeded so we can tell "original still running"
405
+ // apart from "original gone and recreate failed = data loss."
406
+ let deleted = false
407
+ try {
408
+ const delRes = await fetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
409
+ if (!delRes.ok) {
410
+ const body = await delRes.json().catch(() => ({}))
411
+ throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
412
+ }
413
+ deleted = true
414
+ const res = await fetch(apiUrl('/portforwards'), {
415
+ method: 'POST',
416
+ headers: { 'Content-Type': 'application/json' },
417
+ body: JSON.stringify({
418
+ namespace: session.namespace,
419
+ podName: session.podName || undefined,
420
+ serviceName: session.serviceName || undefined,
421
+ podPort: session.podPort,
422
+ localPort: session.localPort,
423
+ listenAddress: newAddress,
424
+ }),
425
+ })
426
+ if (!res.ok) {
427
+ const error = await res.json().catch(() => ({}))
428
+ throw new Error(error.error || `Failed to restart port forward (HTTP ${res.status})`)
429
+ }
430
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
431
+ } catch (error) {
432
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
433
+ const msg = error instanceof Error ? error.message : 'Failed to change network access'
434
+ if (deleted) {
435
+ // DELETE succeeded but POST failed — the original forward is gone.
436
+ showError(
437
+ 'Port forward lost',
438
+ `Forward on port ${session.localPort} (${prevLabel}) was stopped but recreating it as ${nextLabel} failed: ${msg}`
439
+ )
440
+ } else {
441
+ // DELETE failed — the original forward is still running.
442
+ showError('Failed to change network access', msg)
443
+ }
444
+ console.error('Failed to toggle listen address:', error)
445
+ } finally {
446
+ setTogglingId(null)
447
+ }
448
+ }
449
+
450
+ const changeLocalPort = async (session: PortForwardSession, newPort: number) => {
451
+ commitInteraction()
452
+ if (newPort === session.localPort) {
453
+ setEditingPortId(null)
454
+ return
455
+ }
456
+ setChangingPortId(session.id)
457
+ setEditingPortId(null)
458
+ // Track whether the DELETE half succeeded so we can tell "original still running"
459
+ // apart from "original gone and recreate failed = data loss."
460
+ let deleted = false
461
+ try {
462
+ const delRes = await fetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
463
+ if (!delRes.ok) {
464
+ const body = await delRes.json().catch(() => ({}))
465
+ throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
466
+ }
467
+ deleted = true
468
+ const res = await fetch(apiUrl('/portforwards'), {
469
+ method: 'POST',
470
+ headers: { 'Content-Type': 'application/json' },
471
+ body: JSON.stringify({
472
+ namespace: session.namespace,
473
+ podName: session.podName || undefined,
474
+ serviceName: session.serviceName || undefined,
475
+ podPort: session.podPort,
476
+ localPort: newPort,
477
+ listenAddress: session.listenAddress,
478
+ }),
479
+ })
480
+ if (!res.ok) {
481
+ const error = await res.json().catch(() => ({}))
482
+ throw new Error(error.error || `Failed to restart port forward (HTTP ${res.status})`)
483
+ }
484
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
485
+ showSuccess('Port forward updated', `Now listening on localhost:${newPort}`)
486
+ } catch (error) {
487
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
488
+ const msg = error instanceof Error ? error.message : 'Failed to change local port'
489
+ if (deleted) {
490
+ showError(
491
+ 'Port forward lost',
492
+ `Forward on port ${session.localPort} was stopped but port ${newPort} failed: ${msg}`
493
+ )
494
+ } else {
495
+ // DELETE failed — the original forward on session.localPort is still running.
496
+ showError('Failed to change local port', msg)
497
+ }
498
+ console.error('Failed to change local port:', error)
499
+ } finally {
500
+ setChangingPortId(null)
501
+ }
502
+ }
503
+
504
+ const handleCopyUrl = useCallback(
505
+ async (session: PortForwardSession) => {
506
+ commitInteraction()
507
+ try {
508
+ await navigator.clipboard.writeText(`http://localhost:${session.localPort}`)
509
+ } catch (err) {
510
+ // Clipboard API can reject in non-secure contexts, denied permissions, or
511
+ // when the document isn't focused. Surface the failure — the checkmark
512
+ // would otherwise lie to the user.
513
+ const msg = err instanceof Error ? err.message : 'Clipboard access denied'
514
+ showError('Failed to copy URL', msg)
515
+ console.error('Failed to copy URL:', err)
516
+ return
517
+ }
518
+ setCopiedId(session.id)
519
+ if (copyResetTimerRef.current) clearTimeout(copyResetTimerRef.current)
520
+ copyResetTimerRef.current = setTimeout(() => {
521
+ setCopiedId(null)
522
+ copyResetTimerRef.current = null
523
+ }, 2000)
524
+ },
525
+ [commitInteraction, showError]
526
+ )
527
+
528
+ const handleOpenUrl = useCallback(
529
+ (session: PortForwardSession) => {
530
+ commitInteraction()
531
+ openExternal(`http://localhost:${session.localPort}`)
532
+ },
533
+ [commitInteraction]
534
+ )
535
+
536
+ // Unmount when there are no sessions AND the query isn't reporting a fault.
537
+ // Distinguishing a failed query from "no sessions" keeps us from silently
538
+ // telling the user their forwards vanished when really /api/portforwards errored.
539
+ if (activeSessions.length === 0 && !isLoading && !isQueryError) {
540
+ return null
541
+ }
542
+
543
+ const hasErrors = errorSessions.length > 0
544
+
545
+ return (
546
+ // Wrapper — positioning + opacity. Opacity fades fast (150ms), separately from the
547
+ // height reveal (300ms) so users perceive the panel as "there" before it finishes growing.
548
+ <div
549
+ onMouseEnter={onPanelHoverEnter}
550
+ onMouseLeave={onPanelHoverLeave}
551
+ className={clsx(
552
+ 'fixed z-[51] w-80',
553
+ 'transition-opacity duration-150 ease-out',
554
+ isPanelOpen
555
+ ? 'opacity-100 pointer-events-auto'
556
+ : 'opacity-0 pointer-events-none'
557
+ )}
558
+ style={{
559
+ top: anchor?.top ?? 56,
560
+ right: anchor?.right ?? 16,
561
+ }}
562
+ aria-hidden={!isPanelOpen}
563
+ >
564
+ {/* Panel shell — visual chrome (border, shadow, bg, corners). Height is driven
565
+ by the grid-sizer child. As the grid grows 0→auto, the shell grows with it,
566
+ keeping border and rounded corners correct at every intermediate height. */}
567
+ <div className="overflow-hidden rounded-xl bg-theme-surface dark:bg-theme-elevated border-2 border-skyhook-500/35 dark:border-skyhook-400/40 shadow-2xl dark:shadow-[0_24px_60px_-12px_rgba(0,0,0,0.75),0_10px_24px_-6px_rgba(0,0,0,0.45)]">
568
+
569
+ {/* Grid sizer — the height engine. grid-template-rows 0fr→1fr animates
570
+ height from 0 to auto. Content clips from the bottom up, creating a
571
+ natural top-to-bottom reveal (header appears first, sessions follow). */}
572
+ <div
573
+ className={clsx(
574
+ 'grid transition-[grid-template-rows] duration-300',
575
+ isPanelOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
576
+ )}
577
+ style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
578
+ >
579
+ <div className="overflow-hidden">
580
+
581
+ {/* Header — tinted green when all sessions running, red when any have failed. */}
582
+ <div
583
+ className={clsx(
584
+ 'flex items-center justify-between px-3 py-2 border-b transition-colors duration-200',
585
+ hasErrors
586
+ ? 'bg-red-500/10 dark:bg-red-500/15 border-red-500/25 dark:border-red-500/20'
587
+ : 'bg-green-500/8 dark:bg-green-400/10 border-green-500/20 dark:border-green-400/15'
588
+ )}
589
+ >
590
+ <div className="flex items-center gap-2">
591
+ <Plug className="w-4 h-4 text-accent-text" />
592
+ <span className="text-sm font-medium text-theme-text-primary">Port Forwards</span>
593
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">
594
+ {activeSessions.length}
595
+ </span>
596
+ {errorSessions.length > 0 && (
597
+ <span className={clsx('badge-sm', SEVERITY_BADGE.error)}>
598
+ {errorSessions.length} failed
599
+ </span>
600
+ )}
601
+ </div>
602
+ <button
603
+ type="button"
604
+ onClick={minimizePanel}
605
+ aria-label="Minimize port forwards"
606
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
607
+ >
608
+ <ChevronUp className="w-4 h-4" />
609
+ </button>
610
+ </div>
611
+
612
+ {/* Sessions list */}
613
+ <div className="max-h-64 overflow-y-auto">
614
+ {isQueryError ? (
615
+ <div className="p-3 text-xs bg-red-500/10 border-b border-theme-border">
616
+ <div className={clsx('badge-sm mb-1 inline-block', SEVERITY_BADGE.error)}>
617
+ Connection error
618
+ </div>
619
+ <div className="text-red-400 break-all">
620
+ Failed to load port forwards: {queryError?.message ?? 'unknown error'}
621
+ </div>
622
+ </div>
623
+ ) : null}
624
+ {isLoading ? (
625
+ <div className="flex items-center justify-center p-4">
626
+ <Loader2 className="w-5 h-5 text-theme-text-tertiary animate-spin" />
627
+ </div>
628
+ ) : activeSessions.length === 0 ? (
629
+ <div className="p-4 text-center text-sm text-theme-text-disabled">
630
+ {isQueryError ? 'Unable to load port forwards' : 'No active port forwards'}
631
+ </div>
632
+ ) : (
633
+ <div className="divide-y divide-theme-border">
634
+ {activeSessions.map((session) => (
635
+ <div
636
+ key={session.id}
637
+ className={clsx(
638
+ 'p-3',
639
+ session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
640
+ )}
641
+ >
642
+ <div className="flex items-start justify-between gap-2">
643
+ <div className="flex-1 min-w-0">
644
+ <div className="flex items-center gap-2">
645
+ <span
646
+ className={clsx(
647
+ 'w-2 h-2 rounded-full shrink-0',
648
+ session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
649
+ )}
650
+ />
651
+ <span className="text-sm text-theme-text-primary font-medium truncate">
652
+ {session.serviceName || session.podName}
653
+ </span>
654
+ {session.status === 'error' && (
655
+ <span className={clsx('badge-sm', SEVERITY_BADGE.error)}>Failed</span>
656
+ )}
657
+ </div>
658
+ <div className="mt-1 text-xs text-theme-text-disabled">
659
+ {session.namespace} · Port {session.podPort}
660
+ </div>
661
+ {session.status === 'error' && session.error && (
662
+ <div className="mt-1.5 text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
663
+ {session.error}
664
+ </div>
665
+ )}
666
+ {session.status === 'running' && (
667
+ <div className="mt-1.5 flex items-center gap-2">
668
+ {editingPortId === session.id ? (
669
+ <div className="flex items-center text-xs bg-theme-base rounded text-accent-text font-mono">
670
+ <span className="pl-2 py-1 text-theme-text-disabled select-none">
671
+ {session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
672
+ </span>
673
+ <input
674
+ type="number"
675
+ autoFocus
676
+ min={1}
677
+ max={65535}
678
+ value={editPortValue}
679
+ onChange={(e) => {
680
+ // Any keystroke is a deliberate user action — keep the panel open.
681
+ commitInteraction()
682
+ setEditPortValue(e.target.value)
683
+ }}
684
+ onKeyDown={(e) => {
685
+ if (e.key === 'Enter') {
686
+ const val = Number(editPortValue)
687
+ if (
688
+ isNaN(val) ||
689
+ val < 1 ||
690
+ val > 65535 ||
691
+ !Number.isInteger(val)
692
+ ) {
693
+ commitInteraction()
694
+ showError(
695
+ 'Invalid port',
696
+ 'Port must be a number between 1 and 65535'
697
+ )
698
+ return
699
+ }
700
+ changeLocalPort(session, val)
701
+ } else if (e.key === 'Escape') {
702
+ commitInteraction()
703
+ setEditingPortId(null)
704
+ }
705
+ }}
706
+ onBlur={() => setEditingPortId(null)}
707
+ className="w-16 bg-transparent border-none pr-2 py-1 text-accent-text font-mono text-xs outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
708
+ />
709
+ </div>
710
+ ) : (
711
+ <Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
712
+ <code
713
+ className={clsx(
714
+ 'group/port text-xs bg-theme-base px-2 py-1 rounded text-accent-text transition-all inline-flex items-center gap-1',
715
+ changingPortId === session.id
716
+ ? 'opacity-50'
717
+ : 'cursor-pointer hover:ring-1 hover:ring-blue-500/50'
718
+ )}
719
+ onClick={() => {
720
+ if (changingPortId || togglingId) return
721
+ commitInteraction()
722
+ setEditingPortId(session.id)
723
+ setEditPortValue(String(session.localPort))
724
+ }}
725
+ >
726
+ {changingPortId === session.id && (
727
+ <Loader2 className="w-3 h-3 animate-spin inline mr-1" />
728
+ )}
729
+ {session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
730
+ {session.localPort}
731
+ <PenLine className="w-3 h-3 text-theme-text-disabled opacity-0 group-hover/port:opacity-100 transition-opacity" />
732
+ </code>
733
+ </Tooltip>
734
+ )}
735
+ <Tooltip
736
+ content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
737
+ delay={300} position="bottom" disabled={!isPanelOpen}
738
+ >
739
+ <button
740
+ onClick={() => toggleListenAddress(session)}
741
+ disabled={togglingId === session.id || changingPortId === session.id}
742
+ className={clsx(
743
+ 'flex items-center gap-1 px-1.5 py-0.5 text-xs rounded transition-colors',
744
+ session.listenAddress === '0.0.0.0'
745
+ ? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
746
+ : 'bg-theme-elevated text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary'
747
+ )}
748
+ >
749
+ {togglingId === session.id ? (
750
+ <Loader2 className="w-3 h-3 animate-spin" />
751
+ ) : session.listenAddress === '0.0.0.0' ? (
752
+ <Globe className="w-3 h-3" />
753
+ ) : (
754
+ <Monitor className="w-3 h-3" />
755
+ )}
756
+ {session.listenAddress === '0.0.0.0' ? 'network' : 'local'}
757
+ </button>
758
+ </Tooltip>
759
+ </div>
760
+ )}
761
+ </div>
762
+
763
+ <div className="flex items-center gap-1 shrink-0">
764
+ {session.status === 'running' && (
765
+ <>
766
+ <Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
767
+ <button
768
+ onClick={() => handleCopyUrl(session)}
769
+ className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
770
+ >
771
+ {copiedId === session.id ? (
772
+ <Check className="w-3.5 h-3.5 text-green-400" />
773
+ ) : (
774
+ <Copy className="w-3.5 h-3.5" />
775
+ )}
776
+ </button>
777
+ </Tooltip>
778
+ <Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
779
+ <button
780
+ onClick={() => handleOpenUrl(session)}
781
+ className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
782
+ >
783
+ <ExternalLink className="w-3.5 h-3.5" />
784
+ </button>
785
+ </Tooltip>
786
+ </>
787
+ )}
788
+ <Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
789
+ <button
790
+ onClick={() => {
791
+ commitInteraction()
792
+ stopPortForward(session.id)
793
+ }}
794
+ disabled={stoppingIds.has(session.id)}
795
+ className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
796
+ >
797
+ <Trash2 className="w-3.5 h-3.5" />
798
+ </button>
799
+ </Tooltip>
800
+ </div>
801
+ </div>
802
+ </div>
803
+ ))}
804
+ </div>
805
+ )}
806
+ </div>
807
+
808
+ </div>{/* /overflow-hidden */}
809
+ </div>{/* /grid-sizer */}
810
+ </div>{/* /panel-shell */}
811
+
812
+ {/* Caret — rendered after the shell so it paints on top (z-10). Opaque fill
813
+ covers the shell's border at the junction. The tint layer matches the header. */}
814
+ <div
815
+ className="absolute -top-[6px] w-3.5 h-3.5 rotate-45 z-10 bg-theme-surface dark:bg-theme-elevated border-t-2 border-l-2 border-skyhook-500/35 dark:border-skyhook-400/40"
816
+ style={{ right: anchor?.caretRight ?? 16 }}
817
+ >
818
+ <div className={clsx(
819
+ 'absolute inset-0 transition-colors duration-200',
820
+ hasErrors ? 'bg-red-500/10 dark:bg-red-500/15' : 'bg-green-500/8 dark:bg-green-400/10'
821
+ )} />
822
+ </div>
823
+ </div>
824
+ )
825
+ }
826
+
827
+ // --- Public mutation hook for starting a forward -----------------------------
828
+ // Stable hook shape — callers don't need to know about the panel UI. The provider's
829
+ // count-watch effect reacts to the new session and handles open/auto-minimize.
830
+
831
+ export function useStartPortForward() {
832
+ const queryClient = useQueryClient()
833
+
834
+ return useMutation({
835
+ mutationFn: async (req: {
836
+ namespace: string
837
+ podName?: string
838
+ serviceName?: string
839
+ podPort: number
840
+ localPort?: number
841
+ listenAddress?: string // "127.0.0.1" (default) or "0.0.0.0"
842
+ }) => {
843
+ const res = await fetch(apiUrl('/portforwards'), {
844
+ method: 'POST',
845
+ headers: { 'Content-Type': 'application/json' },
846
+ body: JSON.stringify(req),
847
+ })
848
+ if (!res.ok) {
849
+ const error = await res.json().catch(() => ({}))
850
+ throw new Error(error.error || 'Failed to start port forward')
851
+ }
852
+ return res.json() as Promise<PortForwardSession>
853
+ },
854
+ meta: {
855
+ errorMessage: 'Failed to start port forward',
856
+ // No successMessage — the panel auto-opens on new sessions and provides
857
+ // strictly more information than a toast ("started" → here are the details).
858
+ // Only the error toast remains as the signal-of-last-resort when the
859
+ // mutation fails and no panel update can happen.
860
+ },
861
+ onSuccess: () => {
862
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
863
+ },
864
+ })
865
+ }
866
+
867
+ // Backwards-compat: existing consumers that just want a count number.
868
+ export function usePortForwardCount() {
869
+ const { data: sessions = [] } = usePortForwardQuery()
870
+ return sessions.filter((s) => s.status !== 'stopped').length
871
+ }