From 4ac430c1fe04416844ae8f9102528fb30afb6650 Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 4 Jan 2026 13:33:59 +0700 Subject: [PATCH] update: get active forwarder from api --- app/dashboard/dashboard-client.tsx | 235 +++++++++++++++++++++++++++++ app/dashboard/page.tsx | 211 ++------------------------ app/page.tsx | 2 +- drizzle.config.ts | 2 +- lib/auth-client.ts | 8 +- lib/auth.ts | 11 ++ lib/schema/auth.ts | 132 ++++++++++------ 7 files changed, 359 insertions(+), 242 deletions(-) create mode 100644 app/dashboard/dashboard-client.tsx diff --git a/app/dashboard/dashboard-client.tsx b/app/dashboard/dashboard-client.tsx new file mode 100644 index 0000000..71e2c8d --- /dev/null +++ b/app/dashboard/dashboard-client.tsx @@ -0,0 +1,235 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import TunnelConfig, { type TunnelConfig as TunnelConfigType, type Server } from "@/components/tunnel-config" +import { authClient } from "@/lib/auth-client" + +const defaultConfig: TunnelConfigType = { + type: "http", + serverPort: 443, + localPort: 8000, +} + +const formatStartedAgo = (timestamp?: ApiTimestamp): string | undefined => { + if (!timestamp) return undefined + const startedMs = timestamp.seconds * 1000 + Math.floor(timestamp.nanos / 1_000_000) + const diffSeconds = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)) + + if (diffSeconds < 60) return `${diffSeconds}s ago` + const diffMinutes = Math.floor(diffSeconds / 60) + if (diffMinutes < 60) return `${diffMinutes}m ago` + const diffHours = Math.floor(diffMinutes / 60) + if (diffHours < 24) return `${diffHours}h ago` + const diffDays = Math.floor(diffHours / 24) + return `${diffDays}d ago` +} + +const toActiveConnection = (session: ApiSession): ActiveConnection => { + const startedAgo = formatStartedAgo(session.started_at) + + return { + id: session.slug || `${session.node}-${session.started_at?.seconds ?? Date.now()}`, + name: session.slug || session.node || "Unknown tunnel", + status: session.active ? "connected" : "error", + protocol: (session.forwarding_type || "http").toLowerCase(), + serverLabel: session.node || "Unknown node", + remote: session.slug ? `${session.slug}.tunnl.live` : session.node || "—", + startedAgo, + latencyMs: null, + dataInOut: undefined, + } +} + +type ApiTimestamp = { + seconds: number + nanos: number +} + +type ApiSession = { + node: string + forwarding_type: "HTTP" | "HTTPS" | "TCP" | string + slug: string + user_id: string + active: boolean + started_at?: ApiTimestamp +} + +type ApiSessionList = ApiSession[] + +type SessionResponse = Awaited> + +interface DashboardClientProps { + initialActiveConnections: ApiSessionList +} + +type ActiveConnectionStatus = "connected" | "pending" | "error" + +type ActiveConnection = { + id: string + name: string + status: ActiveConnectionStatus + protocol: string + serverLabel: string + remote: string + localPort?: number + serverPort?: number + startedAgo?: string + latencyMs?: number | null + dataInOut?: string +} + +export default function DashboardClient({ initialActiveConnections }: DashboardClientProps) { + const [selectedServer, setSelectedServer] = useState(null) + const [tunnelConfig, setTunnelConfig] = useState(defaultConfig) + const [statusMessage, setStatusMessage] = useState(null) + const [activeConnections, setActiveConnections] = useState( + initialActiveConnections.map(toActiveConnection), + ) + const [session, setSession] = useState(null) + + useEffect(() => { + setActiveConnections(initialActiveConnections.map(toActiveConnection)) + }, [initialActiveConnections]) + + useEffect(() => { + const fetchSession = async () => { + try { + const result = await authClient.getSession() + if (result.data) { + setSession(result.data) + } + } catch (error) { + console.error("Error fetching session", error) + } + } + + fetchSession() + }, []) + + const stopConnection = (id: string) => { + setActiveConnections((prev) => prev.filter((conn) => conn.id !== id)) + setStatusMessage("Connection stopped") + } + + return ( +
+
+
+

Active Forwarding

+

Live tunnels for this session.

+
+ + {statusMessage && ( +
+ {statusMessage} +
+ )} + +
+
+
+

Active Connections

+

Monitor and manage your running tunnels

+
+ + View logs + +
+ + {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} +

+

{connection.remote || "—"}

+

{metaText}

+
+ +
+
+

Latency

+

+ {connection.latencyMs != null ? `${connection.latencyMs}ms` : "—"} +

+
+
+

Data

+

{connection.dataInOut || "—"}

+
+ +
+
+ ) + })} +
+ )} +
+ +
+
+
+

Custom Tunnel Configuration

+

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

+
+ + View docs + +
+ + +
+
+
+ ) +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index a89a1d0..8569883 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,208 +1,27 @@ -"use client" - -import { useEffect, useState } from "react" -import Link from "next/link" -import TunnelConfig, { type TunnelConfig as TunnelConfigType, type Server } from "@/components/tunnel-config" -import { authClient } from "@/lib/auth-client" import SiteHeader from "@/components/site-header" import SiteFooter from "@/components/site-footer" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import DashboardClient from "./dashboard-client" -const defaultConfig: TunnelConfigType = { - type: "http", - serverPort: 443, - localPort: 8000, -} +export default async function DashboardPage() { + const { token } = await auth.api.getToken({ + headers: await headers(), + }) -type ActiveConnection = { - id: string - name: string - serverLabel: string - protocol: TunnelConfigType["type"] - localPort: number - serverPort: number - remote: string - status: "connected" | "pending" | "error" - latencyMs: number | null - dataInOut: string - startedAgo: string -} - -export default function DashboardPage() { - const [selectedServer, setSelectedServer] = useState(null) - const [tunnelConfig, setTunnelConfig] = useState(defaultConfig) - const [statusMessage, setStatusMessage] = useState(null) - const [activeConnections, setActiveConnections] = useState([ - { - id: "conn-1", - name: "Frontend Preview", - serverLabel: "Singapore", - protocol: "http", - localPort: 3000, - serverPort: 443, - remote: "https://sgp.tunnl.live", - status: "connected", - latencyMs: 34, - dataInOut: "1.2 GB", - startedAgo: "3h 12m", + const data = await fetch(`${process.env.API_URL}/api/sessions`, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}`, }, - { - id: "conn-2", - name: "Game TCP", - serverLabel: "Frankfurt", - protocol: "tcp", - localPort: 25565, - serverPort: 20555, - remote: "tcp://eu.tunnl.live:20555", - status: "connected", - latencyMs: 120, - dataInOut: "320 MB", - startedAgo: "54m", - }, - ]) - - type SessionResponse = Awaited> - const [session, setSession] = useState(null) - - useEffect(() => { - const fetchSession = async () => { - try { - const result = await authClient.getSession() - if (result.data) { - setSession(result.data) - } - } catch (error) { - console.error("Error fetching session", error) - } - } - - fetchSession() - }, []) - - const stopConnection = (id: string) => { - setActiveConnections((prev) => prev.filter((conn) => conn.id !== id)) - setStatusMessage("Connection stopped") - } + cache: "no-store", + }) + const initialActiveConnections = await data.json() return (
- -
-
-
-

Active Forwarding

-

Live tunnels for this session.

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

Active Connections

-

Monitor and manage your running tunnels

-
- - View logs - -
- - {activeConnections.length === 0 ? ( -
- No active connections yet. Configure a tunnel to see it here. -
- ) : ( -
- {activeConnections.map((connection) => ( -
-
-
- {connection.name} - - {connection.status === "connected" - ? "Connected" - : connection.status === "pending" - ? "Reconnecting" - : "Error"} - -
-

- {connection.protocol.toUpperCase()} · {connection.serverLabel} -

-

{connection.remote}

-

- Local {connection.localPort} → Server {connection.serverPort} · {connection.startedAgo} -

-
- -
-
-

Latency

-

- {connection.latencyMs ? `${connection.latencyMs}ms` : "—"} -

-
-
-

Data

-

{connection.dataInOut}

-
- -
-
- ))} -
- )} -
- -
-
-
-

Custom Tunnel Configuration

-

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

-
- - View docs - -
- - -
-
-
- +
) diff --git a/app/page.tsx b/app/page.tsx index 6784352..ebb29bd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -53,7 +53,7 @@ export default function Home() { selectedServer={selectedServer} onServerSelect={setSelectedServer} isAuthenticated={logedin != null ? true : false} - userId={logedin?.id} + userId={logedin?.sshIdentifier} />
diff --git a/drizzle.config.ts b/drizzle.config.ts index 2e8ee3b..ac90739 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "postgresql", - schema: "./app/db/schema/*", + schema: "./lib/schema/*", out: "./drizzle", dbCredentials: { url: process.env.DATABASE_URL! diff --git a/lib/auth-client.ts b/lib/auth-client.ts index 498340a..def166c 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -1,2 +1,8 @@ import { createAuthClient } from "better-auth/react" -export const authClient = createAuthClient({}) \ No newline at end of file +import { jwtClient } from "better-auth/client/plugins" +import { inferAdditionalFields } from "better-auth/client/plugins"; +import { auth } from "@/lib/auth" + +export const authClient = createAuthClient({ + plugins: [jwtClient(), inferAdditionalFields()], +}) \ No newline at end of file diff --git a/lib/auth.ts b/lib/auth.ts index d55d5a8..e2e87ee 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,14 +2,25 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "@/lib/db"; import * as schema from "@/lib/schema/auth" +import { jwt } from "better-auth/plugins"; export const auth = betterAuth({ + plugins: [jwt()], socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, }, + user: { + additionalFields: { + sshIdentifier: { + type: "string", + nullable: false, + input: false, + } + }, + }, database: drizzleAdapter(db, { provider: "pg", schema: schema diff --git a/lib/schema/auth.ts b/lib/schema/auth.ts index 112a90d..b4cb5b0 100644 --- a/lib/schema/auth.ts +++ b/lib/schema/auth.ts @@ -1,8 +1,15 @@ -import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"; +import { relations, sql } from "drizzle-orm"; +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), name: text("name").notNull(), + sshIdentifier: text("ssh_identifier") + .notNull() + .unique() + .default( + sql`substr(encode(gen_random_bytes(16), 'hex'), 1, 32)` + ), email: text("email").notNull().unique(), emailVerified: boolean("email_verified").default(false).notNull(), image: text("image"), @@ -13,49 +20,88 @@ export const user = pgTable("user", { .notNull(), }); -export const session = pgTable("session", { +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); + +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +export const jwks = pgTable("jwks", { id: text("id").primaryKey(), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + createdAt: timestamp("created_at").notNull(), + expiresAt: timestamp("expires_at"), }); -export const account = pgTable("account", { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at"), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), -}); +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), +})); -export const verification = pgTable("verification", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), -}); +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +}));