Files
linumiq_net-web_app/app/admin/tunnels/tunnels-table.tsx
T
Gerhard Scheikl d317e8c758 feat(admin): live redis kill-switch on tunnel actions, sortable columns + CSV export + bulk actions, Node 24 LTS
WS1: pin all Docker stages to node:24.16.0-alpine; add engines node>=20.
WS2: lib/redis.ts gains TTL-backed redisSet, redisDel, setTunnelActive (writes tunnel:active:<sub>=1/0 EX 30, TUNNEL_ACTIVE_TTL override, no-op without REDIS_URL); wired into tunnel active/delete/reassign routes.
WS3: sortable columns, CSV export routes (token excluded), and bulk actions (self-account guard) across users/tunnels/audit admin tables.
2026-05-31 14:46:22 +02:00

549 lines
17 KiB
TypeScript

'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<TunnelItem[]>(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<SortOrder>('desc');
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 [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkProgress, setBulkProgress] = useState<string | null>(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<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.`,
);
}
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<boolean>,
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<boolean> {
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<boolean> {
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 (
<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 className="toolbar-actions">
<button
className="secondary btn-sm"
type="button"
onClick={exportCsv}
>
Export CSV
</button>
</div>
</div>
{error && <p className="error">{error}</p>}
{notice && <p className="success">{notice}</p>}
{selectedCount > 0 && (
<div className="bulk-bar">
<span className="bulk-count">{selectedCount} selected</span>
<button
className="secondary btn-sm"
type="button"
disabled={bulkBusy}
onClick={() => runBulk('Activated', (t) => setActiveOne(t, true))}
>
Activate
</button>
<button
className="secondary btn-sm"
type="button"
disabled={bulkBusy}
onClick={() =>
runBulk('Deactivated', (t) => setActiveOne(t, false))
}
>
Deactivate
</button>
<button
className="btn-danger btn-sm"
type="button"
disabled={bulkBusy}
onClick={() =>
runBulk(
'Deleted',
deleteOne,
`Delete ${selectedCount} selected tunnel(s)? This frees their subdomains.`,
)
}
>
Delete
</button>
<button
className="secondary btn-sm"
type="button"
disabled={bulkBusy}
onClick={() => setSelected(new Set())}
>
Clear
</button>
{bulkProgress && <span className="muted">{bulkProgress}</span>}
</div>
)}
{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 className="col-check">
<input
type="checkbox"
aria-label="Select all on page"
checked={allSelected}
onChange={toggleAll}
/>
</th>
<SortHeader
label="Subdomain"
col="subdomain"
sort={sort}
order={order}
onSort={onSort}
/>
<th>Owner</th>
<SortHeader
label="Status"
col="is_active"
sort={sort}
order={order}
onSort={onSort}
/>
<SortHeader
label="Usage"
col="usage_pct"
sort={sort}
order={order}
onSort={onSort}
/>
<SortHeader
label="Last seen"
col="last_seen_at"
sort={sort}
order={order}
onSort={onSort}
/>
<th>Actions</th>
</tr>
</thead>
<tbody>
{tunnels.map((t) => (
<tr
key={t.user_id}
className={selected.has(t.user_id) ? 'row-selected' : undefined}
>
<td className="col-check">
<input
type="checkbox"
aria-label={`Select ${t.subdomain}`}
checked={selected.has(t.user_id)}
onChange={() => toggleRow(t.user_id)}
/>
</td>
<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>
);
}