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.
This commit is contained in:
Gerhard Scheikl
2026-05-31 14:46:22 +02:00
parent 1adb6e7b3f
commit d317e8c758
22 changed files with 1296 additions and 173 deletions
+64 -11
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatDate } from '@/lib/format';
import type { AuditItem } from '@/lib/admin/list';
import { SortHeader, downloadUrl, type SortOrder } from '../table-ui';
const PER_PAGE = 50;
@@ -18,20 +19,28 @@ export function AuditTable({
const [page, setPage] = useState(1);
const [action, setAction] = useState('');
const [targetType, setTargetType] = 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 queryParams = useCallback(() => {
const params = new URLSearchParams({
page: String(page),
perPage: String(PER_PAGE),
sort,
order,
});
if (action.trim()) params.set('action', action.trim());
if (targetType.trim()) params.set('target_type', targetType.trim());
return params;
}, [page, action, targetType, sort, order]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(page),
perPage: String(PER_PAGE),
});
if (action.trim()) params.set('action', action.trim());
if (targetType.trim()) params.set('target_type', targetType.trim());
const res = await fetch(`/api/admin/audit?${params.toString()}`, {
const res = await fetch(`/api/admin/audit?${queryParams().toString()}`, {
credentials: 'same-origin',
});
if (!res.ok) {
@@ -49,7 +58,7 @@ export function AuditTable({
} finally {
setLoading(false);
}
}, [page, action, targetType]);
}, [queryParams]);
// First page is server-rendered; skip the on-mount fetch to avoid racing the
// SSR session-cookie refresh (which intermittently 401'd).
@@ -63,6 +72,23 @@ export function AuditTable({
return () => clearTimeout(t);
}, [load]);
function onSort(col: string) {
setPage(1);
if (col === sort) {
setOrder((o) => (o === 'asc' ? 'desc' : 'asc'));
} else {
setSort(col);
setOrder('asc');
}
}
function exportCsv() {
const params = new URLSearchParams({ sort, order });
if (action.trim()) params.set('action', action.trim());
if (targetType.trim()) params.set('target_type', targetType.trim());
downloadUrl(`/api/admin/audit/export?${params.toString()}`);
}
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
return (
@@ -93,6 +119,15 @@ export function AuditTable({
<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>}
@@ -106,9 +141,27 @@ export function AuditTable({
<table className="admin-table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Action</th>
<SortHeader
label="When"
col="created_at"
sort={sort}
order={order}
onSort={onSort}
/>
<SortHeader
label="Actor"
col="actor_email"
sort={sort}
order={order}
onSort={onSort}
/>
<SortHeader
label="Action"
col="action"
sort={sort}
order={order}
onSort={onSort}
/>
<th>Target</th>
<th>Details</th>
</tr>