179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import {
|
|
Server,
|
|
Search,
|
|
Wifi,
|
|
WifiOff,
|
|
Clock,
|
|
MapPin,
|
|
Shield,
|
|
} from "lucide-react";
|
|
|
|
interface TunnelConnection {
|
|
id: string;
|
|
is_pending_reconnect: boolean;
|
|
origin_ip: string;
|
|
opened_at: string;
|
|
}
|
|
|
|
interface Tunnel {
|
|
id: string;
|
|
name: string;
|
|
status: string;
|
|
created_at: string;
|
|
deleted_at: string | null;
|
|
connections: TunnelConnection[];
|
|
}
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return "Just now";
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|
const days = Math.floor(hrs / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
return `${Math.floor(days / 30)}mo ago`;
|
|
}
|
|
|
|
export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
|
|
const [search, setSearch] = useState("");
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.toLowerCase();
|
|
if (!q) return tunnels;
|
|
return tunnels.filter(
|
|
(t) =>
|
|
t.name.toLowerCase().includes(q) || t.id.toLowerCase().includes(q)
|
|
);
|
|
}, [tunnels, search]);
|
|
|
|
const activeCount = tunnels.filter(
|
|
(t) => t.connections && t.connections.length > 0
|
|
).length;
|
|
|
|
return (
|
|
<div className="min-h-[calc(100vh-3.5rem)]">
|
|
<div className="fixed inset-0 -z-10">
|
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(16,185,129,0.08),transparent)]" />
|
|
<div className="absolute inset-0 bg-zinc-950" style={{ zIndex: -1 }} />
|
|
</div>
|
|
|
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 mb-8">
|
|
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
|
|
<Server className="w-5 h-5 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Tunnels</h1>
|
|
<p className="text-sm text-zinc-500">
|
|
{tunnels.length} total · {activeCount} active
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative mb-6">
|
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name or ID..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full rounded-xl border border-zinc-800 bg-zinc-900/80 backdrop-blur-sm pl-10 pr-4 py-2.5 text-sm text-zinc-200 placeholder-zinc-600 outline-none transition focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/20"
|
|
/>
|
|
</div>
|
|
|
|
{/* Empty */}
|
|
{filtered.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-24 text-zinc-500">
|
|
<Server className="w-12 h-12 opacity-30 mb-4" />
|
|
<p className="text-lg font-medium text-zinc-400">
|
|
No tunnels found
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tunnel cards */}
|
|
<div className="space-y-3">
|
|
{filtered.map((tunnel) => {
|
|
const isActive =
|
|
tunnel.connections && tunnel.connections.length > 0;
|
|
return (
|
|
<div
|
|
key={tunnel.id}
|
|
className="rounded-xl border border-zinc-800/80 bg-zinc-900/60 backdrop-blur-sm p-5 transition hover:border-zinc-700"
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
{/* Left */}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{isActive ? (
|
|
<Wifi className="w-4 h-4 text-emerald-400 shrink-0" />
|
|
) : (
|
|
<WifiOff className="w-4 h-4 text-zinc-600 shrink-0" />
|
|
)}
|
|
<span className="font-semibold text-white truncate">
|
|
{tunnel.name}
|
|
</span>
|
|
<span
|
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
isActive
|
|
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
|
|
: "bg-zinc-800 text-zinc-500 border border-zinc-700"
|
|
}`}
|
|
>
|
|
{isActive ? "Active" : "Inactive"}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-zinc-600 font-mono truncate">
|
|
{tunnel.id}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Right meta */}
|
|
<div className="text-right text-xs text-zinc-600 shrink-0">
|
|
<div className="flex items-center gap-1 justify-end">
|
|
<Clock className="w-3 h-3" />
|
|
Created {timeAgo(tunnel.created_at)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connections */}
|
|
{isActive && tunnel.connections.length > 0 && (
|
|
<div className="mt-3 pt-3 border-t border-zinc-800/50 flex flex-wrap gap-2">
|
|
{tunnel.connections.map((conn) => (
|
|
<div
|
|
key={conn.id}
|
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-zinc-800/50 text-xs text-zinc-400"
|
|
>
|
|
<MapPin className="w-3 h-3 text-zinc-600" />
|
|
{conn.origin_ip}
|
|
<span className="text-zinc-600">·</span>
|
|
<span className="text-zinc-600">
|
|
{timeAgo(conn.opened_at)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="mt-14 pt-6 border-t border-zinc-900 flex items-center gap-2 text-xs text-zinc-600">
|
|
<Shield className="w-3 h-3" />
|
|
<span>Cloudflare Tunnel · auto-refreshes every 30s</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|