import React, { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/button"; import { Card } from "@/components/card"; import { AlertCircle, Clock, Network, Play, RefreshCw, Settings, Square, Wifi, WifiOff, } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { getSSHHosts, subscribeTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel, logActivity, } from "@/main-axios"; import type { Host as DemoHost } from "@/types/ui-types"; import type { SSHHost, TunnelConnection, TunnelStatus } from "@/types"; function tunnelName( host: SSHHost, index: number, tunnel: TunnelConnection, ): string { const hostKey = host.name || `${host.username}@${host.ip}`; return `${host.id}::${index}::${hostKey}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; } function statusLabel(status: TunnelStatus | undefined): string { if (!status) return "DISCONNECTED"; const s = status.status?.toUpperCase(); if (s === "CONNECTED") return "CONNECTED"; if (s === "CONNECTING" || s === "VERIFYING") return "CONNECTING"; if (s === "FAILED") return "ERROR"; if (s === "RETRYING" || s === "WAITING") return "WAITING"; if (s === "DISCONNECTING") return "DISCONNECTED"; return "DISCONNECTED"; } function TunnelCard({ host, index, tunnel, status, isActing, onAction, }: { host: SSHHost; index: number; tunnel: TunnelConnection; status: TunnelStatus | undefined; isActing: boolean; onAction: (action: "connect" | "disconnect" | "cancel") => void; }) { const { t } = useTranslation(); const [showSettings, setShowSettings] = useState(false); const label = statusLabel(status); const isConnected = label === "CONNECTED"; const isConnecting = label === "CONNECTING"; const isError = label === "ERROR"; const isWaiting = label === "WAITING"; let statusColor = "text-muted-foreground border-border bg-muted/30"; if (isConnected) statusColor = "text-accent-brand border-accent-brand/40 bg-accent-brand/10"; if (isConnecting) statusColor = "text-blue-400 border-blue-400/40 bg-blue-400/10"; if (isError) statusColor = "text-destructive border-destructive/40 bg-destructive/10"; if (isWaiting) statusColor = "text-yellow-500 border-yellow-500/40 bg-yellow-500/10"; const mode = tunnel.mode ?? (tunnel.tunnelType as string) ?? "local"; const destination = mode === "dynamic" ? "SOCKS5 Proxy" : `${tunnel.endpointHost ?? ""}:${tunnel.endpointPort}`; return ( {t("tunnels.port")} {tunnel.sourcePort} {isConnecting || isActing ? ( ) : isConnected ? ( ) : isError ? ( ) : isWaiting ? ( ) : ( )} {isActing ? t("tunnels.working") : label} {t("tunnels.destination")} {destination} {mode} → localhost:{tunnel.sourcePort} {isError && status?.reason && ( {status.reason} )} {showSettings && ( {t("tunnels.host")} {host.name || `${host.username}@${host.ip}`} {t("tunnels.mode")} {mode} {t("tunnels.localPort")} {tunnel.sourcePort} {mode !== "dynamic" && ( <> {t("tunnels.remoteHost")} {tunnel.endpointHost} {t("tunnels.remotePort")} {tunnel.endpointPort} > )} {t("tunnels.maxRetries")} {tunnel.maxRetries} )} {isConnected ? ( onAction("disconnect")} > {t("tunnels.stop")} ) : isConnecting || isWaiting ? ( onAction("cancel")} > {t("tunnels.cancel")} ) : ( onAction("connect")} > {isActing ? ( ) : ( )} {t("tunnels.start")} )} setShowSettings((s) => !s)} > ); } export function TunnelTab({ host }: { label: string; host?: DemoHost }) { const { t } = useTranslation(); const [sshHost, setSshHost] = useState(null); const [tunnelStatuses, setTunnelStatuses] = useState< Record >({}); const [tunnelActions, setTunnelActions] = useState>( {}, ); const activityLoggedRef = React.useRef(false); const fetchHost = useCallback(async () => { if (!host) return; try { const hosts = await getSSHHosts(); const found = hosts.find( (h) => String(h.id) === host.id || h.name === host.name, ); if (found) setSshHost(found); } catch { /* ignore */ } }, [host?.id]); useEffect(() => { fetchHost(); const interval = setInterval(fetchHost, 5000); window.addEventListener("ssh-hosts:changed", fetchHost); return () => { clearInterval(interval); window.removeEventListener("ssh-hosts:changed", fetchHost); }; }, [fetchHost]); useEffect(() => { return subscribeTunnelStatuses(setTunnelStatuses, () => {}); }, []); useEffect(() => { if (!sshHost || activityLoggedRef.current) return; activityLoggedRef.current = true; const name = sshHost.name || `${sshHost.username}@${sshHost.ip}`; logActivity("tunnel", sshHost.id, name).catch(() => { activityLoggedRef.current = false; }); }, [sshHost?.id]); const handleAction = async ( action: "connect" | "disconnect" | "cancel", index: number, ) => { if (!sshHost) return; const tunnel = sshHost.tunnelConnections[index]; if (!tunnel) return; const name = tunnelName(sshHost, index, tunnel); setTunnelActions((prev) => ({ ...prev, [name]: true })); try { if (action === "connect") { const allHosts = await getSSHHosts(); const endpointSsh = allHosts.find( (h) => h.name === tunnel.endpointHost || `${h.username}@${h.ip}` === tunnel.endpointHost, ); await connectTunnel({ name, scope: tunnel.scope ?? "s2s", mode: tunnel.mode ?? (tunnel.tunnelType as "local" | "remote" | "dynamic") ?? "remote", tunnelType: tunnel.tunnelType ?? (tunnel.mode === "local" || tunnel.mode === "remote" ? tunnel.mode : "remote"), bindHost: tunnel.bindHost, targetHost: tunnel.targetHost, sourceHostId: sshHost.id, tunnelIndex: index, hostName: sshHost.name || `${sshHost.username}@${sshHost.ip}`, sourceIP: sshHost.ip, sourceSSHPort: sshHost.port, sourceUsername: sshHost.username, sourcePassword: sshHost.authType === "password" ? sshHost.password : undefined, sourceAuthMethod: sshHost.authType, sourceSSHKey: sshHost.authType === "key" ? sshHost.key : undefined, sourceKeyPassword: sshHost.authType === "key" ? sshHost.keyPassword : undefined, sourceKeyType: sshHost.authType === "key" ? sshHost.keyType : undefined, sourceCredentialId: sshHost.credentialId, endpointHost: tunnel.endpointHost ?? "", endpointIP: endpointSsh?.ip ?? tunnel.endpointHost ?? "", endpointSSHPort: endpointSsh?.port ?? 22, endpointUsername: endpointSsh?.username ?? "", endpointPassword: endpointSsh?.authType === "password" ? endpointSsh.password : undefined, endpointAuthMethod: endpointSsh?.authType ?? "password", endpointSSHKey: endpointSsh?.authType === "key" ? endpointSsh.key : undefined, endpointKeyPassword: endpointSsh?.authType === "key" ? endpointSsh.keyPassword : undefined, endpointKeyType: endpointSsh?.authType === "key" ? endpointSsh.keyType : undefined, endpointCredentialId: endpointSsh?.credentialId, sourcePort: tunnel.sourcePort, endpointPort: tunnel.endpointPort, maxRetries: tunnel.maxRetries, retryInterval: tunnel.retryInterval * 1000, autoStart: tunnel.autoStart, isPinned: sshHost.pin, useSocks5: sshHost.useSocks5, socks5Host: sshHost.socks5Host, socks5Port: sshHost.socks5Port, socks5Username: sshHost.socks5Username, socks5Password: sshHost.socks5Password, }); toast.success(t("tunnels.clientTunnelStarted")); } else if (action === "disconnect") { await disconnectTunnel(name); toast.info(t("tunnels.clientTunnelStopped")); } else { await cancelTunnel(name); toast.info(t("tunnels.canceling")); } } catch (err) { toast.error(t("tunnels.manualControlError")); console.error("Tunnel action failed:", err); } finally { setTunnelActions((prev) => ({ ...prev, [name]: false })); } }; if (!host) { return ( {t("tunnels.noHostSelected")} ); } const tunnels = sshHost?.tunnelConnections ?? []; const connectedCount = tunnels.filter((t, i) => { if (!sshHost) return false; const name = tunnelName(sshHost, i, t); return tunnelStatuses[name]?.status === "connected"; }).length; return ( {host.name} {connectedCount}/{tunnels.length} {t("tunnels.active")} {tunnels.length > 0 ? ( {tunnels.map((tunnel, index) => { if (!sshHost) return null; const name = tunnelName(sshHost, index, tunnel); return ( handleAction(action, index)} /> ); })} ) : ( {t("tunnels.noSshTunnels")} {t("tunnels.createFirstTunnelMessage")} )} ); }
{t("tunnels.createFirstTunnelMessage")}