554 lines
16 KiB
TypeScript
554 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import {
|
|
IconNetwork,
|
|
IconExternalLink,
|
|
IconCopy,
|
|
IconCheck,
|
|
IconRefresh,
|
|
IconPlus,
|
|
IconLoader2,
|
|
IconPlugConnected,
|
|
IconPlugConnectedX,
|
|
IconInfoCircle,
|
|
} from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@kandev/ui/dialog";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { listPorts, listTunnels, type ListeningPort } from "@/lib/api/domains/port-api";
|
|
import { useTunnelActions } from "./use-tunnel-actions";
|
|
import { getBackendConfig } from "@/lib/config";
|
|
import { toast } from "sonner";
|
|
|
|
function buildPortProxyUrl(sessionId: string, port: number): string {
|
|
const backendUrl = getBackendConfig().apiBaseUrl;
|
|
return `${backendUrl}/port-proxy/${sessionId}/${port}/`;
|
|
}
|
|
|
|
function buildTunnelUrl(tunnelPort: number): string {
|
|
const backendUrl = getBackendConfig().apiBaseUrl;
|
|
const { protocol, hostname } = new URL(backendUrl);
|
|
return `${protocol}//${hostname}:${tunnelPort}/`;
|
|
}
|
|
|
|
function InfoTip({ text }: { text: string }) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground/60 shrink-0 cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
|
{text}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function UrlActions({ url }: { url: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const handleCopy = useCallback(() => {
|
|
navigator.clipboard?.writeText(url);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
}, [url]);
|
|
|
|
return (
|
|
<div className="flex items-center gap-0.5">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="cursor-pointer h-7 w-7 p-0"
|
|
onClick={handleCopy}
|
|
>
|
|
{copied ? (
|
|
<IconCheck className="h-3.5 w-3.5 text-green-500" />
|
|
) : (
|
|
<IconCopy className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Copy URL</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button size="sm" variant="ghost" className="cursor-pointer h-7 w-7 p-0" asChild>
|
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
|
<IconExternalLink className="h-3.5 w-3.5" />
|
|
</a>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Open in new tab</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TunnelToggleButton({
|
|
isTunnelActive,
|
|
tunnelPending,
|
|
onStop,
|
|
onToggleForm,
|
|
}: {
|
|
isTunnelActive: boolean;
|
|
tunnelPending?: boolean;
|
|
onStop: () => void;
|
|
onToggleForm: () => void;
|
|
}) {
|
|
const Icon = isTunnelActive ? IconPlugConnectedX : IconPlugConnected;
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className={`cursor-pointer h-7 w-7 p-0 ${isTunnelActive ? "text-destructive hover:text-destructive" : ""}`}
|
|
onClick={isTunnelActive ? onStop : onToggleForm}
|
|
disabled={tunnelPending}
|
|
>
|
|
{tunnelPending ? (
|
|
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Icon className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{isTunnelActive ? "Stop tunnel" : "Start tunnel"}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function PortUrlRow({
|
|
label,
|
|
tip,
|
|
url,
|
|
variant = "outline",
|
|
}: {
|
|
label: string;
|
|
tip: string;
|
|
url: string;
|
|
variant?: "outline" | "default";
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Badge variant={variant} className="text-[10px] px-1.5 py-0 shrink-0">
|
|
{label}
|
|
</Badge>
|
|
<InfoTip text={tip} />
|
|
<span className="text-xs text-muted-foreground truncate min-w-0 flex-1">{url}</span>
|
|
<div className="shrink-0">
|
|
<UrlActions url={url} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PortUrlRows({ proxyUrl, tunnelUrl }: { proxyUrl: string; tunnelUrl: string | null }) {
|
|
return (
|
|
<div className="space-y-1 overflow-hidden">
|
|
<PortUrlRow
|
|
label="Proxy"
|
|
tip="Path-based proxy. Works for APIs but may break web apps that expect to be served at /."
|
|
url={proxyUrl}
|
|
/>
|
|
{tunnelUrl && (
|
|
<PortUrlRow
|
|
label="Tunnel"
|
|
tip="Dedicated port tunnel. App is served at /, so assets and routing work correctly."
|
|
url={tunnelUrl}
|
|
variant="default"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type PortRowProps = {
|
|
port: number;
|
|
address?: string;
|
|
process?: string;
|
|
sessionId: string;
|
|
badge: "Detected" | "Manual";
|
|
tunnelPort?: number;
|
|
tunnelPending?: boolean;
|
|
onTunnelStart: (port: number, requestedPort?: number) => void;
|
|
onTunnelStop: (port: number) => void;
|
|
};
|
|
|
|
function PortRow({
|
|
port,
|
|
address,
|
|
process,
|
|
sessionId,
|
|
badge,
|
|
tunnelPort,
|
|
tunnelPending,
|
|
onTunnelStart,
|
|
onTunnelStop,
|
|
}: PortRowProps) {
|
|
const [showTunnelForm, setShowTunnelForm] = useState(false);
|
|
const [tunnelPortInput, setTunnelPortInput] = useState("");
|
|
const proxyUrl = buildPortProxyUrl(sessionId, port);
|
|
const tunnelUrl = tunnelPort ? buildTunnelUrl(tunnelPort) : null;
|
|
const isTunnelActive = !!tunnelPort;
|
|
|
|
const handleStartTunnel = useCallback(() => {
|
|
const requestedPort = tunnelPortInput ? parseInt(tunnelPortInput, 10) : undefined;
|
|
if (
|
|
tunnelPortInput &&
|
|
(isNaN(requestedPort!) || requestedPort! < 1 || requestedPort! > 65535)
|
|
) {
|
|
toast.error("Enter a valid port (1-65535) or leave blank for random");
|
|
return;
|
|
}
|
|
onTunnelStart(port, requestedPort);
|
|
setShowTunnelForm(false);
|
|
setTunnelPortInput("");
|
|
}, [port, tunnelPortInput, onTunnelStart]);
|
|
|
|
return (
|
|
<div
|
|
data-testid={`port-forward-row-${port}`}
|
|
className="rounded-md bg-muted/40 hover:bg-muted/60 transition-colors px-3 py-2 space-y-1.5"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-sm font-mono font-medium">{port}</span>
|
|
{process && (
|
|
<span className="text-xs text-muted-foreground truncate max-w-[120px]">{process}</span>
|
|
)}
|
|
{address && address !== "0.0.0.0" && address !== "*" && (
|
|
<span className="text-xs text-muted-foreground">{address}</span>
|
|
)}
|
|
<Badge
|
|
variant={badge === "Detected" ? "secondary" : "outline"}
|
|
className="text-[10px] px-1.5 py-0"
|
|
>
|
|
{badge}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
<TunnelToggleButton
|
|
isTunnelActive={isTunnelActive}
|
|
tunnelPending={tunnelPending}
|
|
onStop={() => onTunnelStop(port)}
|
|
onToggleForm={() => setShowTunnelForm((v) => !v)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{showTunnelForm && !isTunnelActive && (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
placeholder="Random"
|
|
value={tunnelPortInput}
|
|
onChange={(e) => setTunnelPortInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleStartTunnel())}
|
|
className="h-7 text-xs w-24"
|
|
min={1}
|
|
max={65535}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="cursor-pointer h-7 text-xs gap-1"
|
|
onClick={handleStartTunnel}
|
|
disabled={tunnelPending}
|
|
>
|
|
Start
|
|
</Button>
|
|
<InfoTip text="Specify a local port or leave blank for a random one. For Docker/K8s, use a port you've pre-exposed." />
|
|
</div>
|
|
)}
|
|
|
|
<PortUrlRows proxyUrl={proxyUrl} tunnelUrl={tunnelUrl} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PortListSection({
|
|
detectedPorts,
|
|
manualPorts,
|
|
sessionId,
|
|
loading,
|
|
loaded,
|
|
onRefresh,
|
|
activeTunnels,
|
|
pendingTunnels,
|
|
onTunnelStart,
|
|
onTunnelStop,
|
|
}: {
|
|
detectedPorts: ListeningPort[];
|
|
manualPorts: number[];
|
|
sessionId: string;
|
|
loading: boolean;
|
|
loaded: boolean;
|
|
onRefresh: () => void;
|
|
activeTunnels: Map<number, number>;
|
|
pendingTunnels: Set<number>;
|
|
onTunnelStart: (port: number, requestedPort?: number) => void;
|
|
onTunnelStop: (port: number) => void;
|
|
}) {
|
|
const detectedPortNumbers = new Set(detectedPorts.map((p) => p.port));
|
|
const uniqueManualPorts = manualPorts.filter((p) => !detectedPortNumbers.has(p));
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium flex items-center gap-1.5">
|
|
Listening Ports
|
|
<InfoTip text="TCP ports with active listeners inside the remote executor. Click refresh to re-scan." />
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
data-testid="port-forward-refresh"
|
|
className="cursor-pointer h-7 gap-1 text-xs"
|
|
onClick={onRefresh}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<IconRefresh className="h-3.5 w-3.5" />
|
|
)}
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{!loaded && !loading && (
|
|
<p className="text-xs text-muted-foreground">Click refresh to detect listening ports.</p>
|
|
)}
|
|
|
|
{loaded && detectedPorts.length === 0 && !loading && (
|
|
<p className="text-xs text-muted-foreground">No listening ports detected.</p>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
{detectedPorts.map((p) => (
|
|
<PortRow
|
|
key={`d-${p.port}`}
|
|
port={p.port}
|
|
address={p.address}
|
|
process={p.process}
|
|
sessionId={sessionId}
|
|
badge="Detected"
|
|
tunnelPort={activeTunnels.get(p.port)}
|
|
tunnelPending={pendingTunnels.has(p.port)}
|
|
onTunnelStart={onTunnelStart}
|
|
onTunnelStop={onTunnelStop}
|
|
/>
|
|
))}
|
|
{uniqueManualPorts.map((port) => (
|
|
<PortRow
|
|
key={`m-${port}`}
|
|
port={port}
|
|
sessionId={sessionId}
|
|
badge="Manual"
|
|
tunnelPort={activeTunnels.get(port)}
|
|
tunnelPending={pendingTunnels.has(port)}
|
|
onTunnelStart={onTunnelStart}
|
|
onTunnelStop={onTunnelStop}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ManualPortInput({ onAdd }: { onAdd: (port: number) => void }) {
|
|
const [value, setValue] = useState("");
|
|
|
|
const handleAdd = useCallback(() => {
|
|
const port = parseInt(value, 10);
|
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
toast.error("Enter a valid port (1-65535)");
|
|
return;
|
|
}
|
|
onAdd(port);
|
|
setValue("");
|
|
}, [value, onAdd]);
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<span className="text-sm font-medium flex items-center gap-1.5">
|
|
Add Port Manually
|
|
<InfoTip text="Add a port that isn't auto-detected. Useful for services not yet started." />
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
data-testid="port-forward-port-input"
|
|
type="number"
|
|
placeholder="Port number"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAdd())}
|
|
className="h-8"
|
|
min={1}
|
|
max={65535}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
data-testid="port-forward-add-button"
|
|
className="cursor-pointer h-8 gap-1"
|
|
onClick={handleAdd}
|
|
>
|
|
<IconPlus className="h-3.5 w-3.5" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PortForwardDialogContent({
|
|
sessionId,
|
|
activeTunnels,
|
|
setActiveTunnels,
|
|
}: {
|
|
sessionId: string;
|
|
activeTunnels: Map<number, number>;
|
|
setActiveTunnels: (updater: (prev: Map<number, number>) => Map<number, number>) => void;
|
|
}) {
|
|
const [detectedPorts, setDetectedPorts] = useState<ListeningPort[]>([]);
|
|
const [manualPorts, setManualPorts] = useState<number[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const { pendingTunnels, handleTunnelStart, handleTunnelStop } = useTunnelActions(
|
|
sessionId,
|
|
setActiveTunnels,
|
|
);
|
|
|
|
// Use a ref so refresh doesn't depend on activeTunnels identity (Map reference
|
|
// changes when listTunnels resolves, which would recreate the callback).
|
|
const activeTunnelsRef = useRef(activeTunnels);
|
|
activeTunnelsRef.current = activeTunnels;
|
|
|
|
const refresh = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const ports = await listPorts(sessionId);
|
|
setDetectedPorts(ports);
|
|
setLoaded(true);
|
|
|
|
const tunnels = activeTunnelsRef.current;
|
|
const detectedSet = new Set(ports.map((p) => p.port));
|
|
const tunnelOnlyPorts = [...tunnels.keys()].filter((p) => !detectedSet.has(p));
|
|
if (tunnelOnlyPorts.length > 0) {
|
|
setManualPorts((prev) => {
|
|
const existing = new Set(prev);
|
|
const toAdd = tunnelOnlyPorts.filter((p) => !existing.has(p));
|
|
return toAdd.length > 0 ? [...prev, ...toAdd] : prev;
|
|
});
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
const handleAddManual = useCallback(
|
|
(port: number) => {
|
|
if (manualPorts.includes(port)) {
|
|
toast.error("Port already added");
|
|
return;
|
|
}
|
|
setManualPorts((prev) => [...prev, port]);
|
|
},
|
|
[manualPorts],
|
|
);
|
|
|
|
return (
|
|
<DialogContent
|
|
data-testid="port-forward-dialog"
|
|
className="sm:max-w-2xl overflow-hidden"
|
|
onOpenAutoFocus={() => !loaded && refresh()}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<IconNetwork className="h-5 w-5" />
|
|
Port Forwarding
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 min-w-0 max-h-[60vh] overflow-y-auto">
|
|
<PortListSection
|
|
detectedPorts={detectedPorts}
|
|
manualPorts={manualPorts}
|
|
sessionId={sessionId}
|
|
loading={loading}
|
|
loaded={loaded}
|
|
onRefresh={refresh}
|
|
activeTunnels={activeTunnels}
|
|
pendingTunnels={pendingTunnels}
|
|
onTunnelStart={handleTunnelStart}
|
|
onTunnelStop={handleTunnelStop}
|
|
/>
|
|
<ManualPortInput onAdd={handleAddManual} />
|
|
</div>
|
|
</DialogContent>
|
|
);
|
|
}
|
|
|
|
export function PortForwardButton({
|
|
isRemoteExecutor,
|
|
sessionId,
|
|
isAgentctlReady,
|
|
}: {
|
|
isRemoteExecutor?: boolean;
|
|
sessionId?: string | null;
|
|
isAgentctlReady?: boolean;
|
|
}) {
|
|
const [activeTunnels, setActiveTunnelsRaw] = useState<Map<number, number>>(new Map());
|
|
const hasActiveTunnels = activeTunnels.size > 0;
|
|
|
|
const setActiveTunnels = useCallback(
|
|
(updater: (prev: Map<number, number>) => Map<number, number>) => {
|
|
setActiveTunnelsRaw((prev) => updater(prev));
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!sessionId || !isAgentctlReady) return;
|
|
listTunnels(sessionId).then((tunnels) => {
|
|
setActiveTunnelsRaw(new Map(tunnels.map((t) => [t.port, t.tunnel_port])));
|
|
});
|
|
}, [sessionId, isAgentctlReady]);
|
|
|
|
if (!isRemoteExecutor || !sessionId || !isAgentctlReady) return null;
|
|
|
|
return (
|
|
<Dialog>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
data-testid="port-forward-button"
|
|
size="sm"
|
|
variant={hasActiveTunnels ? "default" : "outline"}
|
|
className="cursor-pointer px-2"
|
|
>
|
|
<IconNetwork className="h-4 w-4" />
|
|
</Button>
|
|
</DialogTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{hasActiveTunnels
|
|
? `Port Forwarding (${activeTunnels.size} tunnel${activeTunnels.size > 1 ? "s" : ""} active)`
|
|
: "Port Forwarding"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<PortForwardDialogContent
|
|
sessionId={sessionId}
|
|
activeTunnels={activeTunnels}
|
|
setActiveTunnels={setActiveTunnels}
|
|
/>
|
|
</Dialog>
|
|
);
|
|
}
|