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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user