import { useEffect, useState } from "react"; import { AlertCircle, Clock, ExternalLink, Network, Play, RefreshCw, Settings, Square, Wifi, WifiOff, } from "lucide-react"; import { useHost, useToast, useTranslation, type PluginHostRecord, } from "@termix/plugin-sdk/frontend"; import { Button, Card } from "@termix/plugin-sdk/ui"; import type { TunnelConnection, TunnelStatus } from "../shared/types"; import { serverTunnelName, tunnelHostLabel } from "../shared/tunnel-naming"; import { cancelTunnel, connectTunnel, disconnectTunnel, subscribeTunnelStatuses, type TunnelStatusMap, } from "./api"; import { connectRequestFor, hostTunnelSettings, tunnelMode, } from "./host-tunnels"; type StatusLabel = "CONNECTED" | "CONNECTING" | "ERROR" | "WAITING" | "DISCONNECTED"; function statusLabel(status: TunnelStatus | undefined): StatusLabel { const value = status?.status?.toUpperCase(); if (value === "CONNECTED") return "CONNECTED"; if (value === "CONNECTING" || value === "VERIFYING") return "CONNECTING"; if (value === "FAILED") return "ERROR"; if (value === "RETRYING" || value === "WAITING") return "WAITING"; return "DISCONNECTED"; } const STATUS_TEXT_KEYS: Record = { CONNECTED: "tunnels.connected", CONNECTING: "tunnels.connecting", ERROR: "tunnels.error", WAITING: "tunnels.waiting", DISCONNECTED: "tunnels.disconnected", }; function TunnelCard({ host, tunnel, status, isActing, onAction, }: { host: PluginHostRecord; 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 = tunnelMode(tunnel); const destination = mode === "dynamic" ? t("tunnels.socksProxy") : `${tunnel.endpointHost ?? ""}:${tunnel.endpointPort}`; return (
{t("tunnels.port")} {tunnel.sourcePort}
{isConnecting || isActing ? ( ) : isConnected ? ( ) : isError ? ( ) : isWaiting ? ( ) : ( )} {isActing ? t("tunnels.working") : t(STATUS_TEXT_KEYS[label])}
{t("tunnels.destination")} {destination}
{mode} {t("tunnels.listensOn", { port: tunnel.sourcePort })}
{isError && status?.reason && (
{status.reason}
)} {showSettings && (
{t("tunnels.host")} {tunnelHostLabel(host)}
{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 ? ( ) : isConnecting || isWaiting ? ( ) : ( )}
); } /** A host's saved server tunnels, with live status and start/stop controls. */ export function TunnelTab({ host: given }: { host?: PluginHostRecord }) { const { t } = useTranslation(); const toast = useToast(); const live = useHost(given?.id); const host = live ?? given ?? null; const [statuses, setStatuses] = useState({}); const [acting, setActing] = useState>({}); useEffect(() => subscribeTunnelStatuses(setStatuses), []); if (!host) { return (
{t("tunnels.noHostSelected")}
); } const tunnels = hostTunnelSettings(host).connections; const names = tunnels.map((tunnel, index) => serverTunnelName(host, index, tunnel), ); const connectedCount = names.filter( (name) => statuses[name]?.status === "connected", ).length; const handleAction = async ( action: "connect" | "disconnect" | "cancel", index: number, ) => { const tunnel = tunnels[index]; if (!tunnel) return; const name = names[index]; setActing((current) => ({ ...current, [name]: true })); try { if (action === "connect") { await connectTunnel(connectRequestFor(host, index, tunnel)); 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 { toast.error(t("tunnels.manualControlError")); } finally { setActing((current) => ({ ...current, [name]: false })); } }; return (

{host.name}

{t("tunnels.activeCount", { connected: connectedCount, total: tunnels.length, })}
{tunnels.length > 0 ? (
{tunnels.map((tunnel, index) => ( handleAction(action, index)} /> ))}
) : (
{t("tunnels.noSshTunnels")}

{t("tunnels.createFirstTunnelMessage")}

)}
); }