@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.
- package/README.md +67 -0
- package/package.json +80 -0
- package/src/App.tsx +1538 -0
- package/src/RadarApp.tsx +145 -0
- package/src/api/apiResources.ts +28 -0
- package/src/api/client.ts +2583 -0
- package/src/api/config.ts +116 -0
- package/src/api/traffic.ts +139 -0
- package/src/components/ConnectionErrorView.tsx +272 -0
- package/src/components/ContextSwitcher.tsx +481 -0
- package/src/components/DebugOverlay.tsx +94 -0
- package/src/components/UserMenu.tsx +87 -0
- package/src/components/audit/AuditSettingsDialog.tsx +162 -0
- package/src/components/audit/AuditView.tsx +123 -0
- package/src/components/cost/CostTrendChart.tsx +388 -0
- package/src/components/cost/CostView.tsx +545 -0
- package/src/components/dock/BottomDock.tsx +96 -0
- package/src/components/dock/DockContext.tsx +11 -0
- package/src/components/dock/LocalTerminalTab.tsx +22 -0
- package/src/components/dock/LogsTab.tsx +26 -0
- package/src/components/dock/NodeTerminalTab.tsx +50 -0
- package/src/components/dock/TerminalTab.tsx +42 -0
- package/src/components/dock/TrafficFlowListTab.tsx +18 -0
- package/src/components/dock/WorkloadLogsTab.tsx +23 -0
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsActions.tsx +1 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
- package/src/components/gitops/ManagedResourcesList.tsx +1 -0
- package/src/components/gitops/SyncCountdown.tsx +1 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/helm/ChartBrowser.tsx +580 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
- package/src/components/helm/HelmView.tsx +475 -0
- package/src/components/helm/InstallWizard.tsx +1060 -0
- package/src/components/helm/ManifestDiffViewer.tsx +91 -0
- package/src/components/helm/ManifestViewer.tsx +61 -0
- package/src/components/helm/OwnedResources.tsx +465 -0
- package/src/components/helm/RevisionHistory.tsx +167 -0
- package/src/components/helm/ValuesDiffPreview.tsx +190 -0
- package/src/components/helm/ValuesViewer.tsx +365 -0
- package/src/components/helm/helm-utils.ts +37 -0
- package/src/components/home/ActivitySummary.tsx +262 -0
- package/src/components/home/CertificateHealthCard.tsx +105 -0
- package/src/components/home/ClusterHealthCard.tsx +483 -0
- package/src/components/home/CostCard.tsx +112 -0
- package/src/components/home/HealthRing.tsx +1 -0
- package/src/components/home/HelmSummary.tsx +129 -0
- package/src/components/home/HomeView.tsx +224 -0
- package/src/components/home/MCPSetupDialog.tsx +417 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
- package/src/components/home/TopologyPreview.tsx +219 -0
- package/src/components/home/TrafficSummary.tsx +154 -0
- package/src/components/logs/JsonLogLine.tsx +1 -0
- package/src/components/logs/LogCore.tsx +2 -0
- package/src/components/logs/LogsViewer.tsx +44 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
- package/src/components/logs/useLogBuffer.ts +2 -0
- package/src/components/logs/useLogSearch.ts +1 -0
- package/src/components/portforward/PortForwardButton.tsx +375 -0
- package/src/components/portforward/PortForwardManager.tsx +871 -0
- package/src/components/resource/PrometheusCharts.tsx +687 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
- package/src/components/resources/ImageFilesystemModal.tsx +745 -0
- package/src/components/resources/PodFilesystemModal.tsx +407 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
- package/src/components/resources/ResourcesView.tsx +190 -0
- package/src/components/resources/drawer-components.tsx +1 -0
- package/src/components/resources/file-browser-utils.ts +35 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/EventRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +1 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/JobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodRenderer.tsx +94 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/VPARenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
- package/src/components/resources/renderers/argo-cells.tsx +1 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
- package/src/components/resources/renderers/eso-cells.tsx +1 -0
- package/src/components/resources/renderers/flux-cells.tsx +1 -0
- package/src/components/resources/renderers/index.ts +91 -0
- package/src/components/resources/renderers/istio-cells.tsx +1 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
- package/src/components/resources/renderers/keda-cells.tsx +1 -0
- package/src/components/resources/renderers/knative-cells.tsx +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
- package/src/components/resources/renderers/traefik-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-shared.tsx +1 -0
- package/src/components/resources/renderers/velero-cells.tsx +1 -0
- package/src/components/resources/resource-utils-argo.ts +2 -0
- package/src/components/resources/resource-utils-certmanager.ts +2 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -0
- package/src/components/resources/resource-utils-eso.ts +2 -0
- package/src/components/resources/resource-utils-flux.ts +2 -0
- package/src/components/resources/resource-utils-istio.ts +2 -0
- package/src/components/resources/resource-utils-karpenter.ts +2 -0
- package/src/components/resources/resource-utils-keda.ts +2 -0
- package/src/components/resources/resource-utils-knative.ts +2 -0
- package/src/components/resources/resource-utils-kyverno.ts +2 -0
- package/src/components/resources/resource-utils-prometheus.ts +2 -0
- package/src/components/resources/resource-utils-traefik.ts +1 -0
- package/src/components/resources/resource-utils-trivy.ts +2 -0
- package/src/components/resources/resource-utils-velero.ts +2 -0
- package/src/components/resources/resource-utils.ts +5 -0
- package/src/components/settings/SettingsDialog.tsx +537 -0
- package/src/components/shared/CreateResourceDialog.tsx +17 -0
- package/src/components/shared/EditableYamlView.tsx +24 -0
- package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
- package/src/components/timeline/DiffViewer.tsx +1 -0
- package/src/components/timeline/TimelineList.tsx +69 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
- package/src/components/timeline/TimelineView.tsx +157 -0
- package/src/components/timeline/shared.tsx +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
- package/src/components/traffic/TrafficFlowList.tsx +415 -0
- package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
- package/src/components/traffic/TrafficGraph.tsx +1546 -0
- package/src/components/traffic/TrafficView.tsx +1213 -0
- package/src/components/traffic/TrafficWizard.tsx +386 -0
- package/src/components/traffic/index.ts +3 -0
- package/src/components/ui/CodeViewer.tsx +8 -0
- package/src/components/ui/CommandPalette.tsx +460 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
- package/src/components/ui/ErrorBoundary.tsx +46 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
- package/src/components/ui/Markdown.tsx +108 -0
- package/src/components/ui/MetricsChart.tsx +1 -0
- package/src/components/ui/NamespaceSelector.tsx +436 -0
- package/src/components/ui/ResourceBar.tsx +1 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
- package/src/components/ui/Toast.tsx +1 -0
- package/src/components/ui/Tooltip.tsx +1 -0
- package/src/components/ui/UpdateNotification.tsx +299 -0
- package/src/components/ui/YamlEditor.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +532 -0
- package/src/context/ConnectionContext.tsx +173 -0
- package/src/context/ContextSwitchContext.tsx +56 -0
- package/src/context/NavCustomization.tsx +62 -0
- package/src/context/ThemeContext.tsx +97 -0
- package/src/contexts/CapabilitiesContext.tsx +130 -0
- package/src/hooks/useAnimatedUnmount.ts +1 -0
- package/src/hooks/useDesktopDownload.ts +41 -0
- package/src/hooks/useEventSource.ts +262 -0
- package/src/hooks/useFavorites.ts +69 -0
- package/src/hooks/useKeyboardShortcuts.tsx +7 -0
- package/src/hooks/useRefreshAnimation.ts +1 -0
- package/src/index.css +243 -0
- package/src/index.ts +17 -0
- package/src/main.tsx +158 -0
- package/src/types/gitops.ts +2 -0
- package/src/types.ts +3 -0
- package/src/utils/animation.ts +2 -0
- package/src/utils/badge-colors.ts +2 -0
- package/src/utils/context-name.ts +2 -0
- package/src/utils/desktop-download.ts +66 -0
- package/src/utils/desktop-open-folder.ts +21 -0
- package/src/utils/format.ts +2 -0
- package/src/utils/log-format.ts +12 -0
- package/src/utils/navigation.ts +23 -0
- package/src/utils/resource-hierarchy.ts +2 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.ts +2 -0
- package/src/utils/traffic-colors.ts +54 -0
- 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
|
+
}
|