350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
'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<TunnelItem[]>(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<string | null>(null);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
const [busyId, setBusyId] = useState<string | null>(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<unknown | null> {
|
|
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 (
|
|
<div>
|
|
<h1>Tunnels</h1>
|
|
|
|
<div className="row" style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
|
<input
|
|
type="text"
|
|
placeholder="Search subdomain…"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setPage(1);
|
|
setSearch(e.target.value);
|
|
}}
|
|
style={{ maxWidth: 260 }}
|
|
/>
|
|
<select
|
|
value={status}
|
|
onChange={(e) => {
|
|
setPage(1);
|
|
setStatus(e.target.value);
|
|
}}
|
|
style={{
|
|
padding: '0.6rem 0.75rem',
|
|
background: 'var(--bg)',
|
|
color: 'var(--fg)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 6,
|
|
}}
|
|
>
|
|
<option value="">All statuses</option>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
<option value="over_quota">Over quota</option>
|
|
</select>
|
|
<button className="secondary btn-sm" type="button" onClick={load}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{error && <p className="error">{error}</p>}
|
|
{notice && <p className="success">{notice}</p>}
|
|
|
|
{loading ? (
|
|
<p className="muted">Loading…</p>
|
|
) : tunnels.length === 0 ? (
|
|
<p className="muted">No tunnels found.</p>
|
|
) : (
|
|
<div className="admin-table-wrap">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Subdomain</th>
|
|
<th>Owner</th>
|
|
<th>Status</th>
|
|
<th>Usage</th>
|
|
<th>Last seen</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tunnels.map((t) => (
|
|
<tr key={t.user_id}>
|
|
<td>{t.subdomain}</td>
|
|
<td style={{ wordBreak: 'break-all' }}>
|
|
{t.owner_email ?? '—'}
|
|
</td>
|
|
<td>
|
|
{t.is_active ? (
|
|
<span className="badge badge-ok">active</span>
|
|
) : (
|
|
<span className="badge">inactive</span>
|
|
)}
|
|
</td>
|
|
<td style={{ minWidth: 140 }}>
|
|
<div>
|
|
{formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)}
|
|
</div>
|
|
<div className="progress" style={{ marginTop: 4 }}>
|
|
<div
|
|
style={{
|
|
width: `${Math.min(100, t.usage_pct).toFixed(1)}%`,
|
|
background:
|
|
t.usage_pct >= 100
|
|
? 'var(--danger)'
|
|
: 'var(--accent)',
|
|
}}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td>{formatDate(t.last_seen_at)}</td>
|
|
<td>
|
|
<div className="row" style={{ flexWrap: 'wrap', gap: 4 }}>
|
|
<button
|
|
type="button"
|
|
className="secondary btn-sm"
|
|
disabled={busyId === t.user_id}
|
|
onClick={() => onToggleActive(t)}
|
|
>
|
|
{t.is_active ? 'Deactivate' : 'Activate'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary btn-sm"
|
|
disabled={busyId === t.user_id}
|
|
onClick={() => onSetQuota(t)}
|
|
>
|
|
Quota
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary btn-sm"
|
|
disabled={busyId === t.user_id}
|
|
onClick={() => onResetUsage(t)}
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary btn-sm"
|
|
disabled={busyId === t.user_id}
|
|
onClick={() => onReassign(t)}
|
|
>
|
|
Reassign
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary btn-sm"
|
|
disabled={busyId === t.user_id}
|
|
onClick={() => onRegenerate(t)}
|
|
>
|
|
Token
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-danger btn-sm"
|
|
disabled={busyId === t.user_id}
|
|
onClick={() => onDelete(t)}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="row" style={{ marginTop: '1rem' }}>
|
|
<button
|
|
className="secondary btn-sm"
|
|
type="button"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
Prev
|
|
</button>
|
|
<span className="muted">
|
|
Page {page} of {totalPages} ({total} total)
|
|
</span>
|
|
<button
|
|
className="secondary btn-sm"
|
|
type="button"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|