'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { formatBytes, formatDate } from '@/lib/format'; import type { TunnelItem } from '@/lib/admin/list'; const PER_PAGE = 25; export function TunnelsTable({ initialTunnels, initialTotal, }: { initialTunnels: TunnelItem[]; initialTotal: number; }) { const [tunnels, setTunnels] = useState(initialTunnels); const [total, setTotal] = useState(initialTotal); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [status, setStatus] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busyId, setBusyId] = useState(null); const load = useCallback(async () => { setLoading(true); setError(null); try { const params = new URLSearchParams({ page: String(page), perPage: String(PER_PAGE), }); if (search.trim()) params.set('search', search.trim()); if (status) params.set('status', status); const res = await fetch(`/api/admin/tunnels?${params.toString()}`, { credentials: 'same-origin', }); if (!res.ok) { const b = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(b.error ?? `Request failed (${res.status})`); } const data = (await res.json()) as { tunnels: TunnelItem[]; total: number }; setTunnels(data.tunnels); setTotal(data.total); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }, [page, search, status]); // First page is server-rendered; skip the on-mount fetch to avoid racing the // SSR session-cookie refresh (which intermittently 401'd). const didMount = useRef(false); useEffect(() => { if (!didMount.current) { didMount.current = true; return; } const t = setTimeout(load, 250); return () => clearTimeout(t); }, [load]); async function act( id: string, label: string, url: string, init: RequestInit, confirmMsg?: string, ): Promise { if (confirmMsg && !window.confirm(confirmMsg)) return null; setBusyId(id); setError(null); setNotice(null); try { const res = await fetch(url, { credentials: 'same-origin', ...init }); const body = (await res.json().catch(() => ({}))) as { error?: string; [k: string]: unknown; }; if (!res.ok) throw new Error(body.error ?? `Request failed (${res.status})`); setNotice(`${label} succeeded`); await load(); return body; } catch (e) { setError((e as Error).message); return null; } finally { setBusyId(null); } } const jsonInit = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); async function onToggleActive(t: TunnelItem) { await act( t.user_id, t.is_active ? 'Deactivate' : 'Activate', `/api/admin/tunnels/${t.user_id}/active`, jsonInit({ is_active: !t.is_active }), ); } async function onRegenerate(t: TunnelItem) { const body = (await act( t.user_id, 'Regenerate token', `/api/admin/tunnels/${t.user_id}/regenerate-token`, { method: 'POST' }, `Regenerate the token for ${t.subdomain}? The old token stops working.`, )) as { token?: string } | null; if (body?.token) { window.prompt('New token (copy it now):', body.token); } } async function onResetUsage(t: TunnelItem) { await act( t.user_id, 'Reset usage', `/api/admin/tunnels/${t.user_id}/reset-usage`, { method: 'POST' }, `Reset bandwidth usage for ${t.subdomain} to zero?`, ); } async function onSetQuota(t: TunnelItem) { const input = window.prompt( `New quota in GiB for ${t.subdomain}:`, String(Math.round(t.quota_bytes / 1024 ** 3)), ); if (input === null) return; const gib = Number(input); if (!Number.isFinite(gib) || gib <= 0) { setError('Quota must be a positive number of GiB'); return; } await act( t.user_id, 'Set quota', `/api/admin/tunnels/${t.user_id}/quota`, jsonInit({ quota_bytes: Math.round(gib * 1024 ** 3) }), ); } async function onReassign(t: TunnelItem) { const input = window.prompt( `New subdomain for ${t.owner_email ?? t.subdomain}:`, t.subdomain, ); if (input === null) return; await act( t.user_id, 'Reassign', `/api/admin/tunnels/${t.user_id}/reassign`, jsonInit({ subdomain: input.trim().toLowerCase() }), ); } async function onDelete(t: TunnelItem) { await act( t.user_id, 'Delete tunnel', `/api/admin/tunnels/${t.user_id}`, { method: 'DELETE' }, `Delete the tunnel ${t.subdomain}? This frees the subdomain.`, ); } const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); return (

Tunnels

{ setPage(1); setSearch(e.target.value); }} style={{ maxWidth: 260 }} />
{error &&

{error}

} {notice &&

{notice}

} {loading ? (

Loading…

) : tunnels.length === 0 ? (

No tunnels found.

) : (
{tunnels.map((t) => ( ))}
Subdomain Owner Status Usage Last seen Actions
{t.subdomain} {t.owner_email ?? '—'} {t.is_active ? ( active ) : ( inactive )}
{formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)}
= 100 ? 'var(--danger)' : 'var(--accent)', }} />
{formatDate(t.last_seen_at)}
)}
Page {page} of {totalPages} ({total} total)
); }