Files
tunnl_please_frontend/components/tunnel-config.tsx
bagas c36ccc9fc5
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 9m47s
feat: wait before checking other server ping
2025-09-07 13:45:09 +07:00

1112 lines
40 KiB
TypeScript

"use client"
import { useState, useEffect } from "react"
import { ComposableMap, Geographies, Geography, Marker } from "react-simple-maps"
export interface TunnelConfig {
type: "http" | "tcp"
serverPort: number
localPort: number
}
interface Server {
id: string
name: string
location: string
subdomain: string
coordinates: [number, number]
ping: number | null
status: "online" | "offline" | "maintenance"
pingStatus: "idle" | "testing" | "success" | "failed" | "timeout"
capabilities: {
http: boolean
tcp: boolean
}
portRestrictions?: {
allowedRanges?: Array<{ min: number; max: number }>
blockedPorts?: number[]
supportsAutoAssign?: boolean
}
}
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
interface TunnelConfigProps {
config: TunnelConfig
onConfigChange: (config: TunnelConfig) => void
selectedServer: Server | null
onServerSelect: (server: Server) => void
}
const fetchServers = async (): Promise<Server[]> => {
await new Promise((resolve) => setTimeout(resolve, 2000))
const mockServers: Server[] = [
{
id: "us",
name: "United States",
location: "Chicago",
subdomain: "us.tunnl.live",
coordinates: [-87.6298, 41.8781],
ping: null,
status: "online",
pingStatus: "idle",
capabilities: {
http: true,
tcp: false,
},
},
{
id: "eu",
name: "Europe",
location: "Frankfurt",
subdomain: "eu.tunnl.live",
coordinates: [8.6821, 50.1109],
ping: null,
status: "online",
pingStatus: "idle",
capabilities: {
http: true,
tcp: false,
},
},
{
id: "sgp",
name: "Singapore",
location: "Singapore",
subdomain: "sgp.tunnl.live",
coordinates: [103.8198, 1.3521],
ping: null,
status: "online",
pingStatus: "idle",
capabilities: {
http: true,
tcp: true,
},
portRestrictions: {
allowedRanges: [
{ min: 40000, max: 41000 },
],
blockedPorts: [22, 80, 443, 3306, 5432, 6379, 2200],
supportsAutoAssign: true,
},
},
{
id: "id",
name: "Indonesia",
location: "Bogor",
subdomain: "id.tunnl.live",
coordinates: [106.8456, -6.595],
ping: null,
status: "online",
pingStatus: "idle",
capabilities: {
http: true,
tcp: true,
},
portRestrictions: {
blockedPorts: [22, 80, 443, 3306, 5432, 6379, 2200],
supportsAutoAssign: true,
},
},
]
return mockServers.filter((server) => server.status === "online")
}
const testServerPing = (
server: Server,
): Promise<{ server: Server; ping: number | null; status: Server["pingStatus"] }> => {
return new Promise((resolve) => {
const startTime = Date.now()
const timeout = 5000
let resolved = false
const pingUrl = `wss://ping.${server.subdomain}`
try {
const ws = new WebSocket(pingUrl)
const timeoutId = setTimeout(() => {
if (!resolved) {
resolved = true
ws.close()
resolve({
server,
ping: null,
status: "timeout",
})
}
}, timeout)
ws.onopen = () => {
console.log(`Connected to ${pingUrl}`)
}
ws.onmessage = (event) => {
if (event.data === "pong" && !resolved) {
resolved = true
const ping = Date.now() - startTime
clearTimeout(timeoutId)
ws.close()
resolve({
server,
ping,
status: "success",
})
}
}
ws.onclose = (event) => {
if (!resolved) {
resolved = true
clearTimeout(timeoutId)
resolve({
server,
ping: null,
status: "failed",
})
}
}
ws.onerror = (error) => {
if (!resolved) {
resolved = true
clearTimeout(timeoutId)
console.error(`WebSocket error for ${pingUrl}:`, error)
resolve({
server,
ping: null,
status: "failed",
})
}
}
} catch (error) {
console.error(`Failed to create WebSocket for ${pingUrl}:`, error)
resolve({
server,
ping: null,
status: "failed",
})
}
})
}
export default function TunnelConfig({ config, onConfigChange, selectedServer, onServerSelect }: TunnelConfigProps) {
const [localConfig, setLocalConfig] = useState<TunnelConfig>({
...config,
serverPort: config.type === "tcp" ? 0 : config.serverPort,
})
const [servers, setServers] = useState<Server[]>([])
const [isLoadingServers, setIsLoadingServers] = useState(true)
const [isTestingPings, setIsTestingPings] = useState(false)
const [hasAutoTested, setHasAutoTested] = useState(false)
const [copied, setCopied] = useState(false)
const [serverError, setServerError] = useState<string | null>(null)
const [portError, setPortError] = useState<string | null>(null)
const [pendingServerSelection, setPendingServerSelection] = useState<Server | null>(null)
useEffect(() => {
const loadServers = async () => {
try {
setIsLoadingServers(true)
setServerError(null)
const serverData = await fetchServers()
setServers(serverData)
if (serverData.length === 0) {
setServerError("No servers are currently available. Please try again later.")
}
} catch (error) {
setServerError("Failed to load servers. Please check your connection and try again.")
} finally {
setIsLoadingServers(false)
}
}
loadServers()
}, [])
useEffect(() => {
if (servers.length > 0 && !isLoadingServers && !hasAutoTested) {
const autoTestPings = async () => {
setIsTestingPings(true)
setHasAutoTested(true)
setServers((prevServers) =>
prevServers.map((server) => ({
...server,
pingStatus: "testing" as const,
})),
)
try {
const testedServers: Server[] = []
for (const server of servers) {
try {
const result = await testServerPing(server)
const updatedServer = {
...result.server,
ping: result.ping,
pingStatus: result.status,
}
testedServers.push(updatedServer)
setServers((prevServers) => prevServers.map((s) => (s.id === server.id ? updatedServer : s)))
await new Promise((resolve) => setTimeout(resolve, 100))
} catch (error) {
console.error(`Error testing ping for ${server.id}:`, error)
const failedServer = {
...server,
ping: null,
pingStatus: "timeout" as const,
}
testedServers.push(failedServer)
setServers((prevServers) => prevServers.map((s) => (s.id === server.id ? failedServer : s)))
}
}
const compatibleServers = testedServers.filter(
(s) =>
s.pingStatus === "success" &&
s.ping !== null &&
((localConfig.type === "http" && s.capabilities.http) ||
(localConfig.type === "tcp" && s.capabilities.tcp)),
)
if (compatibleServers.length > 0) {
const bestServer = compatibleServers.reduce((prev, current) =>
prev.ping! < current.ping! ? prev : current,
)
setPendingServerSelection(bestServer)
} else {
const successfulServers = testedServers.filter((s) => s.pingStatus === "success" && s.ping !== null)
if (successfulServers.length > 0) {
const bestServer = successfulServers.reduce((prev, current) =>
prev.ping! < current.ping! ? prev : current,
)
setPendingServerSelection(bestServer)
} else if (testedServers.length > 0) {
setPendingServerSelection(testedServers[0])
}
}
} catch (error) {
console.error("Error testing pings:", error)
} finally {
setIsTestingPings(false)
}
}
autoTestPings()
}
}, [servers, isLoadingServers, hasAutoTested, localConfig.type])
useEffect(() => {
if (pendingServerSelection) {
onServerSelect(pendingServerSelection)
setPendingServerSelection(null)
if (localConfig.type === "tcp" && !pendingServerSelection.capabilities.tcp) {
updateConfig({ type: "http", serverPort: 443 })
}
}
}, [pendingServerSelection, onServerSelect, localConfig.type])
useEffect(() => {
if (selectedServer && localConfig.type === "tcp" && !selectedServer.capabilities.tcp) {
updateConfig({ type: "http", serverPort: 443 })
}
}, [selectedServer, localConfig.type])
useEffect(() => {
if (selectedServer && localConfig.type === "tcp" && localConfig.serverPort !== 0) {
const error = validatePort(localConfig.serverPort, selectedServer)
setPortError(error)
} else {
setPortError(null)
}
}, [selectedServer, localConfig.serverPort, localConfig.type])
const validatePort = (port: number, server: Server): string | null => {
if (!server.portRestrictions) return null
const { allowedRanges, blockedPorts } = server.portRestrictions
if (blockedPorts && blockedPorts.includes(port)) {
return `Port ${port} is not available on this server`
}
if (allowedRanges && allowedRanges.length > 0) {
const isInRange = allowedRanges.some((range) => port >= range.min && port <= range.max)
if (!isInRange) {
const rangeStrings = allowedRanges.map((r) => `${r.min}-${r.max}`)
return `Port must be within allowed ranges: ${rangeStrings.join(", ")}`
}
}
if (port < 1024) {
return `Port ${port} is restricted. Please use a port number 1024 or higher.`
}
return null
}
const updateConfig = (updates: Partial<TunnelConfig>) => {
const newConfig = { ...localConfig, ...updates }
if (updates.serverPort && (updates.serverPort === 80 || updates.serverPort === 443)) {
newConfig.type = "http"
}
setLocalConfig(newConfig)
onConfigChange(newConfig)
}
const generateCommand = () => {
if (!selectedServer) return ""
const { serverPort, localPort } = localConfig
return `ssh ${selectedServer.subdomain} -p 2200 -R ${serverPort}:localhost:${localPort}`
}
const copyToClipboard = () => {
const command = generateCommand()
if (command) {
navigator.clipboard.writeText(command)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const getPingColor = (server: Server) => {
if (server.pingStatus === "testing") return "text-gray-400"
if (server.pingStatus === "failed" || server.pingStatus === "timeout") return "text-red-400"
if (server.pingStatus === "idle" || !server.ping) return "text-gray-400"
if (server.ping < 100) return "text-green-400"
if (server.ping < 300) return "text-yellow-400"
if (server.ping < 500) return "text-orange-400"
if (server.ping < 1000) return "text-red-400"
return "text-red-400"
}
const getPingDisplay = (server: Server) => {
if (server.pingStatus === "testing") return "Testing..."
if (server.pingStatus === "timeout") return "Timeout"
if (server.pingStatus === "failed") return "Failed"
if (server.pingStatus === "idle") return "Click to test"
if (server.ping === null) return "N/A"
return `${server.ping}ms`
}
const getPingStatus = (server: Server) => {
if (server.pingStatus === "testing") return "Testing..."
if (server.pingStatus === "timeout") return "Timeout (5s)"
if (server.pingStatus === "failed") return "Connection Failed"
if (server.pingStatus === "idle") return "Not tested"
if (!server.ping) return "Unknown"
if (server.ping < 100) return "Excellent"
if (server.ping < 300) return "Good"
if (server.ping < 500) return "Fair"
if (server.ping < 1000) return "Poor"
return "Very Poor"
}
const getMarkerColor = (server: Server) => {
if (selectedServer?.id === server.id) return "#10b981"
if (server.pingStatus === "failed" || server.pingStatus === "timeout") return "#ef4444"
if (server.pingStatus === "success" && server.ping !== null) {
if (server.ping < 100) return "#10b981"
if (server.ping < 300) return "#eab308"
if (server.ping < 500) return "#f97316"
if (server.ping < 1000) return "#ef4444"
return "#ef4444"
}
return "#6b7280"
}
const getMarkerStroke = (server: Server) => {
if (selectedServer?.id === server.id) return "#34d399"
if (server.pingStatus === "failed" || server.pingStatus === "timeout") return "#f87171"
if (server.pingStatus === "success" && server.ping !== null) {
if (server.ping < 100) return "#34d399"
if (server.ping < 300) return "#facc15"
if (server.ping < 500) return "#fb923c"
if (server.ping < 1000) return "#f87171"
return "#f87171"
}
return "#9ca3af"
}
const testPingForServer = async (server: Server) => {
setServers((prevServers) =>
prevServers.map((s) => (s.id === server.id ? { ...s, pingStatus: "testing", ping: null } : s)),
)
try {
const timeoutPromise = new Promise<{ server: Server; ping: number | null; status: Server["pingStatus"] }>(
(_, reject) => {
setTimeout(() => reject(new Error("Timeout")), 5000)
},
)
const result = await Promise.race([testServerPing(server), timeoutPromise])
setServers((prevServers) =>
prevServers.map((s) => (s.id === server.id ? { ...s, ping: result.ping, pingStatus: result.status } : s)),
)
} catch (error) {
console.error("Error testing ping:", error)
setServers((prevServers) =>
prevServers.map((s) => (s.id === server.id ? { ...s, ping: null, pingStatus: "timeout" } : s)),
)
}
}
const testAllPings = async () => {
if (servers.length === 0 || isTestingPings) return
setIsTestingPings(true)
setServers((prevServers) =>
prevServers.map((server) => ({
...server,
ping: null,
pingStatus: "testing" as const,
})),
)
try {
for (const server of servers) {
try {
const result = await testServerPing(server)
setServers((prevServers) =>
prevServers.map((s) => (s.id === server.id ? { ...s, ping: result.ping, pingStatus: result.status } : s)),
)
await new Promise((resolve) => setTimeout(resolve, 100))
} catch (error) {
console.error(`Error testing ping for ${server.id}:`, error)
setServers((prevServers) =>
prevServers.map((s) => (s.id === server.id ? { ...s, ping: null, pingStatus: "timeout" } : s)),
)
}
}
} catch (error) {
console.error("Error in sequential ping testing:", error)
} finally {
setIsTestingPings(false)
}
}
const canSelectServer = (server: Server) => {
if (server.pingStatus === "failed" || server.pingStatus === "timeout") {
return false
}
if (localConfig.type === "http" && !server.capabilities.http) {
return false
}
if (localConfig.type === "tcp" && !server.capabilities.tcp) {
return false
}
return true
}
const getServerUnavailableReason = (server: Server) => {
if (server.pingStatus === "failed" || server.pingStatus === "timeout") {
return "Server unavailable"
}
if (localConfig.type === "tcp" && !server.capabilities.tcp) {
return "TCP not supported"
}
if (localConfig.type === "http" && !server.capabilities.http) {
return "HTTP not supported"
}
return null
}
const getCompatibleServers = () => {
return servers.filter((server) => {
if (localConfig.type === "http") return server.capabilities.http
if (localConfig.type === "tcp") return server.capabilities.tcp
return true
})
}
const getPortRestrictionInfo = (server: Server) => {
if (!server.portRestrictions) return "Ports: 1024+"
const { allowedRanges } = server.portRestrictions
if (allowedRanges && allowedRanges.length > 0) {
const ranges = allowedRanges.map((r) => `${r.min}-${r.max}`).join(", ")
return `Ports: ${ranges}`
}
return "Ports: 1024+"
}
const compatibleServers = getCompatibleServers()
return (
<div className="bg-gray-900 rounded-lg border border-gray-800 p-6 mb-8">
<h3 className="text-lg font-bold mb-6">Tunnel Configuration</h3>
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-md font-medium">Choose Your Server Location</h4>
{servers.length > 0 && hasAutoTested && (
<button
onClick={testAllPings}
disabled={isTestingPings}
className="text-sm text-emerald-400 hover:text-emerald-300 disabled:text-gray-500 disabled:cursor-not-allowed flex items-center gap-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={isTestingPings ? "animate-spin" : ""}
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
{isTestingPings ? "Testing All..." : "Test All Pings"}
</button>
)}
</div>
{compatibleServers.length === 0 && servers.length > 0 && (
<div className="bg-yellow-950 rounded-lg border border-yellow-800 p-4 mb-4">
<div className="flex items-center gap-2 mb-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-yellow-400"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
<p className="text-yellow-400 font-medium">
No servers support {localConfig.type.toUpperCase()} forwarding
</p>
</div>
<p className="text-yellow-300 text-sm">
Please switch to HTTP/HTTPS forwarding or wait for TCP-compatible servers to come online.
</p>
</div>
)}
{isLoadingServers ? (
<div className="bg-gray-800 rounded-lg border border-gray-700 p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-400 mx-auto mb-4"></div>
<p className="text-gray-400">Loading available servers...</p>
</div>
) : serverError ? (
<div className="bg-red-950 rounded-lg border border-red-800 p-6 text-center">
<div className="mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-red-400 mx-auto"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</svg>
</div>
<p className="text-red-400 font-medium mb-2">Server Unavailable</p>
<p className="text-red-300 text-sm">{serverError}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-red-800 hover:bg-red-700 text-white rounded-lg text-sm transition-colors"
>
Retry
</button>
</div>
) : servers.length === 0 ? (
<div className="bg-yellow-950 rounded-lg border border-yellow-800 p-6 text-center">
<div className="mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-yellow-400 mx-auto"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
</div>
<p className="text-yellow-400 font-medium mb-2">No Servers Available</p>
<p className="text-yellow-300 text-sm">All servers are currently offline for maintenance.</p>
</div>
) : (
<>
<div className="bg-gray-800 rounded-lg border border-gray-700 p-3 mb-4">
<ComposableMap
projection="geoMercator"
projectionConfig={{
scale: 100,
center: [0, 20],
}}
width={600}
height={250}
style={{
width: "100%",
height: "auto",
}}
>
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill="#374151"
stroke="#4b5563"
strokeWidth={0.5}
style={{
default: { outline: "none" },
hover: { outline: "none", fill: "#4b5563" },
pressed: { outline: "none" },
}}
/>
))
}
</Geographies>
{servers.map((server) => (
<Marker
key={server.id}
coordinates={server.coordinates}
onClick={() => {
if (canSelectServer(server)) {
onServerSelect(server)
}
}}
>
<g>
{selectedServer?.id === server.id && (
<circle r="12" fill="none" stroke="#10b981" strokeWidth="2" opacity="0.6">
<animate attributeName="r" values="6;15;6" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.6;0;0.6" dur="2s" repeatCount="indefinite" />
</circle>
)}
<circle r="6" fill={getMarkerColor(server)} stroke={getMarkerStroke(server)} strokeWidth="2" />
<text
textAnchor="middle"
y="-12"
style={{
fontFamily: "system-ui",
fontSize: "10px",
fontWeight: "bold",
fill: "white",
pointerEvents: "none",
}}
>
{server.location}
</text>
</g>
</Marker>
))}
</ComposableMap>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{servers.map((server) => {
const canSelect = canSelectServer(server)
const unavailableReason = getServerUnavailableReason(server)
return (
<div
key={server.id}
onClick={() => {
if (canSelect) {
onServerSelect(server)
}
}}
className={`p-3 rounded-lg border transition-all duration-200 ${
selectedServer?.id === server.id
? "bg-emerald-950 border-emerald-500"
: !canSelect
? "bg-red-950 border-red-800 cursor-not-allowed opacity-75"
: "bg-gray-800 border-gray-700 hover:border-gray-600 cursor-pointer"
}`}
>
<div className="flex items-center justify-between mb-1">
<h5 className="font-medium text-sm">{server.name}</h5>
<div
className={`w-2 h-2 rounded-full ${
selectedServer?.id === server.id
? "bg-emerald-400"
: !canSelect
? "bg-red-400"
: server.pingStatus === "success" && server.ping !== null
? server.ping < 100
? "bg-green-400"
: server.ping < 300
? "bg-yellow-400"
: server.ping < 500
? "bg-orange-400"
: "bg-red-400"
: "bg-gray-600"
}`}
/>
</div>
<p className="text-xs text-gray-400 mb-1">{server.location}</p>
<p className="text-xs font-mono text-gray-500 mb-2">{server.subdomain}</p>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-400">Supports:</span>
<div className="flex gap-1">
{server.capabilities.http && (
<span className="text-xs bg-blue-900 text-blue-300 px-1.5 py-0.5 rounded">HTTP</span>
)}
{server.capabilities.tcp && (
<span className="text-xs bg-purple-900 text-purple-300 px-1.5 py-0.5 rounded">TCP</span>
)}
</div>
</div>
{server.capabilities.tcp && (
<div className="mb-2">
<p className="text-xs text-gray-300">{getPortRestrictionInfo(server)}</p>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs">Ping:</span>
<div className="flex items-center gap-1">
{server.pingStatus === "testing" && (
<div className="animate-spin rounded-full h-3 w-3 border border-gray-400 border-t-transparent"></div>
)}
{hasAutoTested ? (
<button
onClick={(e) => {
e.stopPropagation()
if (server.pingStatus !== "testing") {
testPingForServer(server)
}
}}
disabled={server.pingStatus === "testing"}
className={`text-xs font-bold hover:underline disabled:no-underline disabled:cursor-not-allowed ${getPingColor(
server,
)}`}
>
{getPingDisplay(server)}
</button>
) : (
<span className={`text-xs font-bold ${getPingColor(server)}`}>{getPingDisplay(server)}</span>
)}
</div>
</div>
<p className="text-xs text-gray-500 mt-1">{getPingStatus(server)}</p>
{unavailableReason && (
<p className="text-xs text-red-400 mt-1 font-medium">Cannot select - {unavailableReason}</p>
)}
</div>
)
})}
</div>
</>
)}
</div>
{servers.length > 0 && (
<>
<div className="mb-6">
<label className="block text-sm font-medium mb-3">Forwarding Type</label>
<div className="flex gap-4">
<label
className={`flex items-center ${
servers.some((s) => s.capabilities.http) ? "cursor-pointer" : "cursor-not-allowed opacity-50"
}`}
>
<input
type="radio"
name="forwardingType"
value="http"
checked={localConfig.type === "http"}
onChange={() =>
servers.some((s) => s.capabilities.http) && updateConfig({ type: "http", serverPort: 443 })
}
disabled={!servers.some((s) => s.capabilities.http)}
className="sr-only"
/>
<div
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-all ${
localConfig.type === "http"
? "bg-emerald-950 border-emerald-500 text-emerald-400"
: servers.some((s) => s.capabilities.http)
? "bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600"
: "bg-gray-800 border-gray-700 text-gray-500 cursor-not-allowed"
}`}
>
<div
className={`w-2 h-2 rounded-full ${
localConfig.type === "http"
? "bg-emerald-400"
: servers.some((s) => s.capabilities.http)
? "bg-gray-500"
: "bg-gray-600"
}`}
/>
<span className="font-medium">HTTP/HTTPS</span>
{!servers.some((s) => s.capabilities.http) && (
<span className="text-xs text-gray-500 ml-1">(Unavailable)</span>
)}
</div>
</label>
<label
className={`flex items-center ${
servers.some((s) => s.capabilities.tcp) ? "cursor-pointer" : "cursor-not-allowed opacity-50"
}`}
>
<input
type="radio"
name="forwardingType"
value="tcp"
checked={localConfig.type === "tcp"}
onChange={() =>
servers.some((s) => s.capabilities.tcp) && updateConfig({ type: "tcp", serverPort: 0 })
}
disabled={!servers.some((s) => s.capabilities.tcp)}
className="sr-only"
/>
<div
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-all ${
localConfig.type === "tcp"
? "bg-emerald-950 border-emerald-500 text-emerald-400"
: servers.some((s) => s.capabilities.tcp)
? "bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600"
: "bg-gray-800 border-gray-700 text-gray-500 cursor-not-allowed"
}`}
>
<div
className={`w-2 h-2 rounded-full ${
localConfig.type === "tcp"
? "bg-emerald-400"
: servers.some((s) => s.capabilities.tcp)
? "bg-gray-500"
: "bg-gray-600"
}`}
/>
<span className="font-medium">TCP</span>
{!servers.some((s) => s.capabilities.tcp) && (
<span className="text-xs text-gray-500 ml-1">(Unavailable)</span>
)}
</div>
</label>
</div>
<p className="text-sm text-gray-400 mt-2">
{localConfig.type === "http"
? "Best for web applications and APIs. Uses HTTPS (port 443) or HTTP (port 80)."
: "For any TCP service like databases, game servers, or custom applications."}
</p>
{!servers.some((s) => (localConfig.type === "http" ? s.capabilities.http : s.capabilities.tcp)) && (
<div className="mt-3 p-3 bg-yellow-950 rounded-lg border border-yellow-800">
<div className="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-yellow-400"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
<p className="text-yellow-400 text-sm font-medium">
No servers currently support {localConfig.type.toUpperCase()} forwarding
</p>
</div>
</div>
)}
</div>
<div className="grid gap-4 md:grid-cols-2 mb-6">
<div>
<label className="block text-sm font-medium mb-2">Server Port (Internet Access)</label>
{localConfig.type === "http" ? (
<select
value={localConfig.serverPort}
onChange={(e) => updateConfig({ serverPort: Number.parseInt(e.target.value) })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white font-mono focus:border-emerald-500 focus:outline-none"
>
<option value={443}>443 (HTTPS)</option>
<option value={80}>80 (HTTP)</option>
</select>
) : (
<div className="space-y-2">
<input
type="number"
value={localConfig.serverPort === 0 ? "" : localConfig.serverPort}
onChange={(e) => updateConfig({ serverPort: Number.parseInt(e.target.value) || 0 })}
className={`w-full bg-gray-800 border rounded-lg px-3 py-2 text-white font-mono focus:outline-none ${
portError ? "border-red-500 focus:border-red-400" : "border-gray-700 focus:border-emerald-500"
}`}
placeholder="0 for auto-assign"
min="0"
max="65535"
/>
{portError && <p className="text-xs text-red-400">{portError}</p>}
{localConfig.serverPort === 0 && (
<p className="text-xs text-blue-400">Server will automatically assign an available port</p>
)}
</div>
)}
<p className="text-xs text-gray-400 mt-1">
{localConfig.type === "http"
? "Standard web ports"
: localConfig.serverPort === 0
? "Server will assign an available port automatically"
: "Port accessible from the internet (1024+)"}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">Local Port (Your Service)</label>
<input
type="number"
value={localConfig.localPort}
onChange={(e) => updateConfig({ localPort: Number.parseInt(e.target.value) || 8000 })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white font-mono focus:border-emerald-500 focus:outline-none"
placeholder="8000"
min="1"
max="65535"
/>
<p className="text-xs text-gray-400 mt-1">Port where your local service is running</p>
</div>
</div>
{selectedServer && (
<div className="mb-6">
<label className="block text-sm font-medium mb-2">SSH Command</label>
<div className="relative">
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700 font-mono text-sm overflow-x-auto">
<pre className="whitespace-pre-wrap break-all sm:break-normal">{generateCommand()}</pre>
</div>
<button
onClick={copyToClipboard}
className="absolute right-3 top-3 h-8 w-8 flex items-center justify-center rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
aria-label="Copy command"
>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-emerald-400"
>
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
)}
</button>
</div>
<p className="text-xs text-gray-400 mt-2">Run this command in your terminal to create the tunnel</p>
</div>
)}
<div className="p-3 bg-gray-800 rounded-lg border border-gray-700">
<p className="text-sm text-gray-300">
<span className="font-medium">Traffic Flow:</span> Internet {" "}
<span className="text-emerald-400 font-mono">
{selectedServer ? selectedServer.location : "Server"}:
{localConfig.serverPort === 0 ? "auto" : localConfig.serverPort}
</span>{" "}
<span className="text-emerald-400 font-mono">localhost:{localConfig.localPort}</span>
</p>
<p className="text-xs text-gray-400 mt-1">
{localConfig.type === "http" ? (
<>
Your local service on port {localConfig.localPort} will be accessible via{" "}
{localConfig.serverPort === 443 ? "HTTPS" : "HTTP"}
</>
) : (
<>
TCP traffic to server port {localConfig.serverPort === 0 ? "(auto-assigned)" : localConfig.serverPort}{" "}
will be forwarded to your localhost:
{localConfig.localPort}
</>
)}
</p>
</div>
</>
)}
</div>
)
}
export type { Server }