feat: add tunnel configuration tools
This commit is contained in:
771
components/tunnel-config.tsx
Normal file
771
components/tunnel-config.tsx
Normal file
@ -0,0 +1,771 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
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: "sgp",
|
||||
name: "Singapore",
|
||||
location: "Singapore",
|
||||
subdomain: "sgp.tunnl.live",
|
||||
coordinates: [103.8198, 1.3521],
|
||||
ping: null,
|
||||
status: "online",
|
||||
pingStatus: "idle",
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
name: "Indonesia",
|
||||
location: "Jakarta",
|
||||
subdomain: "id.tunnl.live",
|
||||
coordinates: [106.8456, -6.2088],
|
||||
ping: null,
|
||||
status: "online",
|
||||
pingStatus: "idle",
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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 pingPromises = servers.map((server) => testServerPing(server))
|
||||
const results = await Promise.all(pingPromises)
|
||||
|
||||
const updatedServers = results.map((result) => ({
|
||||
...result.server,
|
||||
ping: result.ping,
|
||||
pingStatus: result.status,
|
||||
}))
|
||||
|
||||
setServers(updatedServers)
|
||||
|
||||
const successfulServers = updatedServers.filter((s) => s.pingStatus === "success" && s.ping !== null)
|
||||
if (successfulServers.length > 0) {
|
||||
const bestServer = successfulServers.reduce((prev, current) =>
|
||||
prev.ping! < current.ping! ? prev : current,
|
||||
)
|
||||
onServerSelect(bestServer)
|
||||
} else if (updatedServers.length > 0) {
|
||||
onServerSelect(updatedServers[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error testing pings:", error)
|
||||
} finally {
|
||||
setIsTestingPings(false)
|
||||
}
|
||||
}
|
||||
|
||||
autoTestPings()
|
||||
}
|
||||
}, [servers.length, isLoadingServers, hasAutoTested, onServerSelect])
|
||||
|
||||
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 < 50) return "text-green-400"
|
||||
if (server.ping < 100) return "text-yellow-400"
|
||||
if (server.ping < 150) return "text-orange-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 < 50) return "Excellent"
|
||||
if (server.ping < 100) return "Good"
|
||||
if (server.ping < 150) return "Fair"
|
||||
return "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 < 50) return "#10b981"
|
||||
if (server.ping < 100) return "#eab308"
|
||||
if (server.ping < 150) return "#f97316"
|
||||
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 < 50) return "#34d399"
|
||||
if (server.ping < 100) return "#facc15"
|
||||
if (server.ping < 150) return "#fb923c"
|
||||
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 {
|
||||
const pingPromises = servers.map((server) => testServerPing(server))
|
||||
const results = await Promise.all(pingPromises)
|
||||
|
||||
const updatedServers = results.map((result) => ({
|
||||
...result.server,
|
||||
ping: result.ping,
|
||||
pingStatus: result.status,
|
||||
}))
|
||||
|
||||
setServers(updatedServers)
|
||||
} catch (error) {
|
||||
console.error("Error testing pings:", error)
|
||||
} finally {
|
||||
setIsTestingPings(false)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{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 (server.pingStatus === "failed" || server.pingStatus === "timeout") {
|
||||
return
|
||||
}
|
||||
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-3">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => {
|
||||
if (server.pingStatus === "failed" || server.pingStatus === "timeout") {
|
||||
return
|
||||
}
|
||||
onServerSelect(server)
|
||||
}}
|
||||
className={`p-3 rounded-lg border transition-all duration-200 ${selectedServer?.id === server.id
|
||||
? "bg-emerald-950 border-emerald-500"
|
||||
: server.pingStatus === "failed" || server.pingStatus === "timeout"
|
||||
? "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"
|
||||
: server.pingStatus === "failed" || server.pingStatus === "timeout"
|
||||
? "bg-red-400"
|
||||
: server.pingStatus === "success" && server.ping !== null
|
||||
? server.ping < 50
|
||||
? "bg-green-400"
|
||||
: server.ping < 100
|
||||
? "bg-yellow-400"
|
||||
: server.ping < 150
|
||||
? "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 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>
|
||||
{(server.pingStatus === "failed" || server.pingStatus === "timeout") && (
|
||||
<p className="text-xs text-red-400 mt-1 font-medium">Cannot select - Server unavailable</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 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="forwardingType"
|
||||
value="http"
|
||||
checked={localConfig.type === "http"}
|
||||
onChange={() => updateConfig({ type: "http", serverPort: 443 })}
|
||||
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"
|
||||
: "bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${localConfig.type === "http" ? "bg-emerald-400" : "bg-gray-500"}`}
|
||||
/>
|
||||
<span className="font-medium">HTTP/HTTPS</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="forwardingType"
|
||||
value="tcp"
|
||||
checked={localConfig.type === "tcp"}
|
||||
onChange={() => updateConfig({ type: "tcp", serverPort: 8080 })}
|
||||
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"
|
||||
: "bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${localConfig.type === "tcp" ? "bg-emerald-400" : "bg-gray-500"}`}
|
||||
/>
|
||||
<span className="font-medium">TCP</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>
|
||||
</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>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
value={localConfig.serverPort}
|
||||
onChange={(e) => updateConfig({ serverPort: Number.parseInt(e.target.value) || 8080 })}
|
||||
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="8080"
|
||||
min="1024"
|
||||
max="65535"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{localConfig.type === "http" ? "Standard web ports" : "Port accessible from the internet"}
|
||||
</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}
|
||||
</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} will be forwarded to your localhost:
|
||||
{localConfig.localPort}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { Server }
|
183
components/world-map.tsx
Normal file
183
components/world-map.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ComposableMap, Geographies, Geography, Marker } from "react-simple-maps"
|
||||
|
||||
interface Server {
|
||||
id: string
|
||||
name: string
|
||||
location: string
|
||||
subdomain: string
|
||||
coordinates: [number, number] // [longitude, latitude]
|
||||
ping: number | null
|
||||
}
|
||||
|
||||
const servers: Server[] = [
|
||||
{
|
||||
id: "sgp",
|
||||
name: "Singapore",
|
||||
location: "Singapore",
|
||||
subdomain: "sgp.tunnl.live",
|
||||
coordinates: [103.8198, 1.3521],
|
||||
ping: null,
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
name: "Indonesia",
|
||||
location: "Jakarta",
|
||||
subdomain: "id.tunnl.live",
|
||||
coordinates: [106.8456, -6.2088],
|
||||
ping: null,
|
||||
},
|
||||
]
|
||||
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
|
||||
|
||||
interface WorldMapProps {
|
||||
onServerSelect: (server: Server) => void
|
||||
selectedServer: Server
|
||||
}
|
||||
|
||||
export default function WorldMap({ onServerSelect, selectedServer }: WorldMapProps) {
|
||||
const [serverPings, setServerPings] = useState<Server[]>(servers)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const getPingColor = (ping: number | null) => {
|
||||
if (!ping) return "text-gray-400"
|
||||
if (ping < 50) return "text-green-400"
|
||||
if (ping < 100) return "text-yellow-400"
|
||||
if (ping < 150) return "text-orange-400"
|
||||
return "text-red-400"
|
||||
}
|
||||
|
||||
const getPingStatus = (ping: number | null) => {
|
||||
if (!ping) return "Testing..."
|
||||
if (ping < 50) return "Excellent"
|
||||
if (ping < 100) return "Good"
|
||||
if (ping < 150) return "Fair"
|
||||
return "Poor"
|
||||
}
|
||||
|
||||
const getMarkerColor = (server: Server) => {
|
||||
if (selectedServer.id === server.id) return "#10b981"
|
||||
return "#6b7280"
|
||||
}
|
||||
|
||||
const getMarkerStroke = (server: Server) => {
|
||||
if (selectedServer.id === server.id) return "#34d399"
|
||||
return "#9ca3af"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h3 className="text-xl font-bold text-center mb-6">Choose Your Server Location</h3>
|
||||
|
||||
<div className="relative bg-gray-900 rounded-lg border border-gray-800 p-4 mb-6 overflow-hidden">
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 120,
|
||||
center: [0, 20],
|
||||
}}
|
||||
width={800}
|
||||
height={400}
|
||||
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>
|
||||
|
||||
{serverPings.map((server) => (
|
||||
<Marker
|
||||
key={server.id}
|
||||
coordinates={server.coordinates}
|
||||
onClick={() => onServerSelect(server)}
|
||||
>
|
||||
<g>
|
||||
{selectedServer.id === server.id && (
|
||||
<circle r="15" fill="none" stroke="#10b981" strokeWidth="2" opacity="0.6">
|
||||
<animate attributeName="r" values="8;20;8" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.6;0;0.6" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
|
||||
<circle
|
||||
r="8"
|
||||
fill={getMarkerColor(server)}
|
||||
stroke={getMarkerStroke(server)}
|
||||
strokeWidth="2"
|
||||
className="transition-all duration-200 hover:r-10"
|
||||
/>
|
||||
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y="-15"
|
||||
style={{
|
||||
fontFamily: "system-ui",
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
fill: "white",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{server.location}
|
||||
</text>
|
||||
</g>
|
||||
</Marker>
|
||||
))}
|
||||
</ComposableMap>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{serverPings.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => onServerSelect(server)}
|
||||
className={`p-4 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||
selectedServer.id === server.id
|
||||
? "bg-emerald-950 border-emerald-500"
|
||||
: "bg-gray-900 border-gray-800 hover:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-bold">{server.name}</h4>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${selectedServer.id === server.id ? "bg-emerald-400" : "bg-gray-600"}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mb-2">{server.location}</p>
|
||||
<p className="text-xs font-mono text-gray-500 mb-2">{server.subdomain}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Ping:</span>
|
||||
{isLoading ? (
|
||||
<span className="text-sm text-gray-400">Testing...</span>
|
||||
) : (
|
||||
<span className={`text-sm font-bold ${getPingColor(server.ping)}`}>
|
||||
{server.ping}ms ({getPingStatus(server.ping)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user