From 4824621d9c8fade6163ec4ca251d57b5e5ecc3eb Mon Sep 17 00:00:00 2001 From: bagas Date: Tue, 6 Jan 2026 18:34:37 +0700 Subject: [PATCH] feat: implement the forwarder termination logic --- app/api/session/[...node]/route.ts | 28 -- app/api/session/[...params]/route.ts | 60 ++++ app/dashboard/dashboard-client.tsx | 504 +++++++++++++++++---------- 3 files changed, 382 insertions(+), 210 deletions(-) delete mode 100644 app/api/session/[...node]/route.ts create mode 100644 app/api/session/[...params]/route.ts diff --git a/app/api/session/[...node]/route.ts b/app/api/session/[...node]/route.ts deleted file mode 100644 index d7e7c45..0000000 --- a/app/api/session/[...node]/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { auth } from "@/lib/auth"; -import { headers } from "next/headers" -import { NextRequest } from "next/server"; - -export async function PATCH(req: NextRequest, context: { params: Promise<{ node: string[] }> }) { - const { node } = await context.params; - const requestHeaders = await headers(); - const result = await auth.api.getToken({ headers: requestHeaders }).catch(() => null); - if (!result || typeof result !== "object" || !("token" in result)) { - return new Response("Unauthorized", { status: 401 }); - } - - const { token } = result as { token: string }; - if (!token) { - return new Response("Unauthorized", { status: 401 }); - } - - const data = await fetch(`${process.env.API_URL}/api/session/${node.join("/")}`, { - method: "PATCH", - headers: { - authorization: `Bearer ${token}`, - }, - cache: "no-store", - body: await req.text(), - }) - - return data; -} \ No newline at end of file diff --git a/app/api/session/[...params]/route.ts b/app/api/session/[...params]/route.ts new file mode 100644 index 0000000..29c8c10 --- /dev/null +++ b/app/api/session/[...params]/route.ts @@ -0,0 +1,60 @@ +import { auth } from "@/lib/auth"; +import { headers } from "next/headers" +import { NextRequest } from "next/server"; + +export async function PATCH(req: NextRequest, context: { params: Promise<{ params: string[] }> }) { + const { params } = await context.params; + const requestHeaders = await headers(); + const result = await auth.api.getToken({ headers: requestHeaders }).catch(() => null); + if (!result || typeof result !== "object" || !("token" in result)) { + return new Response("Unauthorized", { status: 401 }); + } + + const { token } = result as { token: string }; + if (!token) { + return new Response("Unauthorized", { status: 401 }); + } + + const data = await fetch(`${process.env.API_URL}/api/session/${params.join("/")}`, { + method: "PATCH", + headers: { + authorization: `Bearer ${token}`, + }, + cache: "no-store", + body: await req.text(), + }) + + return data; +} + +export async function DELETE(req: NextRequest, context: { params: Promise<{ params: string[] }> }) { + const { params } = await context.params; + if (!params || params.length < 3) { + return new Response("Bad Request", { status: 400 }); + } + const node = params[0] + const tunnelType = params[1] + const slug = params[2] + + const requestHeaders = await headers(); + const result = await auth.api.getToken({ headers: requestHeaders }).catch(() => null); + if (!result || typeof result !== "object" || !("token" in result)) { + return new Response("Unauthorized", { status: 401 }); + } + + const { token } = result as { token: string }; + if (!token) { + return new Response("Unauthorized", { status: 401 }); + } + + const data = await fetch(`${process.env.API_URL}/api/session/${node}/${tunnelType}/${slug}`, { + method: "DELETE", + headers: { + authorization: `Bearer ${token}`, + }, + cache: "no-store", + body: await req.text(), + }) + + return data; +} \ No newline at end of file diff --git a/app/dashboard/dashboard-client.tsx b/app/dashboard/dashboard-client.tsx index ab085d0..9fffd05 100644 --- a/app/dashboard/dashboard-client.tsx +++ b/app/dashboard/dashboard-client.tsx @@ -12,6 +12,8 @@ const defaultConfig: TunnelConfigType = { localPort: 8000, } +const STOP_CONFIRMATION_TEXT = "YEAH SURE BUDDY" + const formatStartedAgo = (timestamp?: ApiTimestamp): string | undefined => { if (!timestamp) return undefined const startedMs = timestamp.seconds * 1000 + Math.floor(timestamp.nanos / 1_000_000) @@ -32,6 +34,7 @@ const toActiveConnection = (session: ApiSession): ActiveConnection => { return { id: session.slug || `${session.node}-${session.started_at?.seconds ?? Date.now()}`, name: session.slug || session.node || "Unknown tunnel", + slug: session.slug, status: session.active ? "connected" : "error", protocol: (session.forwarding_type || "http").toLowerCase(), serverLabel: session.node || "Unknown node", @@ -68,6 +71,7 @@ type ActiveConnectionStatus = "connected" | "pending" | "error" type ActiveConnection = { id: string name: string + slug?: string status: ActiveConnectionStatus protocol: string serverLabel: string @@ -89,6 +93,16 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC const { data: cachedSession } = authClient.useSession() const [session, setSession] = useState(cachedSession ?? null) const [openActionId, setOpenActionId] = useState(null) + const [stopModal, setStopModal] = useState<{ + connectionId: string + node?: string + slug?: string + protocol?: string + name: string + } | null>(null) + const [stopModalInput, setStopModalInput] = useState("") + const [stopSaving, setStopSaving] = useState(false) + const [stopError, setStopError] = useState(null) const [slugModal, setSlugModal] = useState<{ connectionId: string currentSlug: string @@ -108,10 +122,68 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC } }, [cachedSession, session]) - const stopConnection = (id: string) => { - setActiveConnections((prev) => prev.filter((conn) => conn.id !== id)) - setStatusMessage("Connection stopped") - setOpenActionId((current) => (current === id ? null : current)) + const openStopModal = (connection: ActiveConnection) => { + setStopModal({ + connectionId: connection.id, + node: connection.node || connection.serverLabel, + slug: connection.slug || connection.name, + protocol: connection.protocol, + name: connection.name, + }) + setStopModalInput("") + setStopError(null) + setOpenActionId(null) + } + + const closeStopModal = () => { + setStopModal(null) + setStopModalInput("") + setStopSaving(false) + setStopError(null) + } + + const stopConnection = async () => { + if (!stopModal) return + + if (stopModalInput.trim() !== STOP_CONFIRMATION_TEXT) { + setStopError("Please type the confirmation phrase exactly.") + return + } + + const node = stopModal.node + const tunnelType = stopModal.protocol?.toLowerCase() + const slug = stopModal.slug + + if (!node || !tunnelType || !slug) { + setStopError("Missing connection details; cannot stop.") + return + } + + setStopSaving(true) + setStopError(null) + setStatusMessage(null) + + try { + const endpoint = `/api/session/${encodeURIComponent(node)}/${encodeURIComponent(tunnelType)}/${encodeURIComponent(slug)}` + const response = await fetch(endpoint, { + method: "DELETE", + }) + + if (!response.ok) { + const message = await response.text() + setStopError(message || "Failed to stop connection.") + return + } + + setActiveConnections((prev) => prev.filter((conn) => conn.id !== stopModal.connectionId)) + setStatusMessage("Connection stopped") + closeStopModal() + } catch (error) { + console.error("Failed to stop connection", error) + setStopError("Failed to stop connection.") + } finally { + setStopSaving(false) + } } const openChangeSlugModal = (connection: ActiveConnection) => { @@ -166,10 +238,10 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC prev.map((conn) => conn.id === slugModal.connectionId ? { - ...conn, - name: trimmedSlug, - remote: conn.node ? `${trimmedSlug}.${conn.node}` : trimmedSlug, - } + ...conn, + name: trimmedSlug, + remote: conn.node ? `${trimmedSlug}.${conn.node}` : trimmedSlug, + } : conn, ), ) @@ -185,20 +257,21 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC } useEffect(() => { - if (slugModal) { + if (slugModal || stopModal) { const previousOverflow = document.body.style.overflow document.body.style.overflow = "hidden" return () => { document.body.style.overflow = previousOverflow } } - }, [slugModal]) + }, [slugModal, stopModal]) useEffect(() => { const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpenActionId(null) setSlugModal(null) + setStopModal(null) } } @@ -206,210 +279,277 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC return () => window.removeEventListener("keydown", closeOnEscape) }, []) + const stopModalContent = !stopModal + ? null + : ( +
+
+
+
+
+ + + +
+
+

Stop connection

+

+ Type {STOP_CONFIRMATION_TEXT} to stop {stopModal.slug || stopModal.name} on {stopModal.node || "this node"}. +

+
+
+ +
+ +
+ + {stopError &&

{stopError}

} +
+ + +
+
+
+
+ ) + const slugModalContent = !slugModal ? null : ( -
-
-
-
-

Change slug

-

Update the identifier for this tunnel.

-
+
+
+
+
+

Change slug

+

Update the identifier for this tunnel.

+
+ +
+ +
+ + +
+
- - - - -
- - -
-
-
+
- ) +
+ ) return ( <>
-
-
-

Active Forwarding

-

Live tunnels for this session.

-
- - {statusMessage && ( -
- {statusMessage} -
- )} - -
-
-
-

Active Connections

-

Monitor and manage your running tunnels

-
- +
+
+

Active Forwarding

+

Live tunnels for this session.

- {activeConnections.length === 0 ? ( -
- No active connections yet. Configure a tunnel to see it here. + {statusMessage && ( +
+ {statusMessage}
- ) : ( -
- {activeConnections.map((connection) => { - const metaParts: string[] = [] - if (connection.localPort && connection.serverPort) { - metaParts.push(`Local ${connection.localPort} → Server ${connection.serverPort}`) - } - if (connection.startedAgo) { - metaParts.push(connection.startedAgo) - } - const metaText = metaParts.length > 0 ? metaParts.join(" · ") : "No session metadata yet" + )} - return ( -
-
-
- {connection.name} - +
+
+

Active Connections

+

Monitor and manage your running tunnels

+
+ +
+ + {activeConnections.length === 0 ? ( +
+ No active connections yet. Configure a tunnel to see it here. +
+ ) : ( +
+ {activeConnections.map((connection) => { + const metaParts: string[] = [] + if (connection.localPort && connection.serverPort) { + metaParts.push(`Local ${connection.localPort} → Server ${connection.serverPort}`) + } + if (connection.startedAgo) { + metaParts.push(connection.startedAgo) + } + const metaText = metaParts.length > 0 ? metaParts.join(" · ") : "No session metadata yet" + + return ( +
+
+
+ {connection.name} + - {connection.status === "connected" - ? "Connected" - : connection.status === "pending" - ? "Reconnecting" - : "Error"} - -
-

- {(connection.protocol || "http").toUpperCase()} · {connection.serverLabel} -

- {(() => { - const isTcp = connection.protocol === "tcp" - const isHttp = connection.protocol === "http" || connection.protocol === "https" - const httpRemote = connection.remote ? `https://${connection.remote}` : "—" - const tcpRemote = - connection.node && connection.name - ? `tcp://${connection.node}:${connection.name}` - : connection.remote || "—" - - const displayRemote = isTcp ? tcpRemote : isHttp ? httpRemote : connection.remote || "—" - - return

{displayRemote}

- })()} -

{metaText}

-
- -
- - - {openActionId === connection.id && ( -
- - {connection.protocol !== "tcp" && ( + {connection.status === "connected" + ? "Connected" + : connection.status === "pending" + ? "Reconnecting" + : "Error"} + +
+

+ {(connection.protocol || "http").toUpperCase()} · {connection.serverLabel} +

+ {(() => { + const isTcp = connection.protocol === "tcp" + const isHttp = connection.protocol === "http" || connection.protocol === "https" + const httpRemote = connection.remote ? `https://${connection.remote}` : "—" + const tcpRemote = + connection.node && connection.name + ? `tcp://${connection.node}:${connection.name}` + : connection.remote || "—" + + const displayRemote = isTcp ? tcpRemote : isHttp ? httpRemote : connection.remote || "—" + + return

{displayRemote}

+ })()} +

{metaText}

+
+ +
+ + + {openActionId === connection.id && ( +
+ {connection.protocol !== "tcp" && ( + + )} -
- )} +
+ )} +
-
- ) - })} -
- )} -
- -
-
-
-

Custom Tunnel Configuration

-

Pick a location, test latency, and shape your tunnel exactly how you need.

-
- - View docs - + ) + })} +
+ )}
- +
+
+
+

Custom Tunnel Configuration

+

Pick a location, test latency, and shape your tunnel exactly how you need.

+
+ + View docs + +
+ + +
-
+ {stopModalContent} {slugModalContent} )