'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { formatBytes, formatDate } from '@/lib/format'; import type { TunnelItem } from '@/lib/admin/list'; import { SortHeader, downloadUrl, type SortOrder } from '../table-ui'; const PER_PAGE = 25; type BulkResult = { ok: number; fail: number }; 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 [sort, setSort] = useState('created_at'); const [order, setOrder] = useState('desc'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busyId, setBusyId] = useState(null); const [selected, setSelected] = useState>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); const [bulkProgress, setBulkProgress] = useState(null); const queryParams = useCallback(() => { const params = new URLSearchParams({ page: String(page), perPage: String(PER_PAGE), sort, order, }); if (search.trim()) params.set('search', search.trim()); if (status) params.set('status', status); return params; }, [page, search, status, sort, order]); const load = useCallback(async () => { setLoading(true); setError(null); try { const res = await fetch(`/api/admin/tunnels?${queryParams().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); setSelected(new Set()); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }, [queryParams]); // 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]); function onSort(col: string) { setPage(1); if (col === sort) { setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); } else { setSort(col); setOrder('asc'); } } 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.`, ); } function toggleRow(id: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } const pageIds = tunnels.map((t) => t.user_id); const allSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id)); function toggleAll() { setSelected((prev) => { if (pageIds.every((id) => prev.has(id))) return new Set(); return new Set(pageIds); }); } async function runBulk( label: string, perItem: (t: TunnelItem) => Promise, confirmMsg?: string, ) { const targets = tunnels.filter((t) => selected.has(t.user_id)); if (targets.length === 0) return; if (confirmMsg && !window.confirm(confirmMsg)) return; setBulkBusy(true); setError(null); setNotice(null); const result: BulkResult = { ok: 0, fail: 0 }; for (let i = 0; i < targets.length; i++) { setBulkProgress(`${label}: ${i + 1}/${targets.length}…`); try { const ok = await perItem(targets[i]); if (ok) result.ok++; else result.fail++; } catch { result.fail++; } } setBulkProgress(null); setBulkBusy(false); const parts = [`${result.ok} ${label.toLowerCase()}`]; if (result.fail) parts.push(`${result.fail} failed`); setNotice(parts.join(', ')); await load(); } async function setActiveOne(t: TunnelItem, active: boolean): Promise { const res = await fetch(`/api/admin/tunnels/${t.user_id}/active`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: active }), }); return res.ok; } async function deleteOne(t: TunnelItem): Promise { const res = await fetch(`/api/admin/tunnels/${t.user_id}`, { method: 'DELETE', credentials: 'same-origin', }); return res.ok; } function exportCsv() { const params = new URLSearchParams({ sort, order }); if (search.trim()) params.set('search', search.trim()); if (status) params.set('status', status); downloadUrl(`/api/admin/tunnels/export?${params.toString()}`); } const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); const selectedCount = selected.size; return (

Tunnels

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

{error}

} {notice &&

{notice}

} {selectedCount > 0 && (
{selectedCount} selected {bulkProgress && {bulkProgress}}
)} {loading ? (

Loading…

) : tunnels.length === 0 ? (

No tunnels found.

) : (
{tunnels.map((t) => ( ))}
Owner Actions
toggleRow(t.user_id)} /> {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)
); }