diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 1a1a91f..e00cdeb 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -34,18 +34,65 @@ export interface ExportCsvResponse { message: string; } +export type TrafficParams = { + period?: number; + limit?: number; + offset?: number; + search?: string; + sort_by?: string; + sort_desc?: boolean; + tariffs?: string; +}; + +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +const trafficCache = new Map(); + +function buildCacheKey(params: TrafficParams): string { + return JSON.stringify({ + p: params.period ?? 30, + l: params.limit ?? 50, + o: params.offset ?? 0, + s: params.search ?? '', + sb: params.sort_by ?? 'total_bytes', + sd: params.sort_desc ?? true, + t: params.tariffs ?? '', + }); +} + export const adminTrafficApi = { - getTrafficUsage: async (params: { - period?: number; - limit?: number; - offset?: number; - search?: string; - sort_by?: string; - sort_desc?: boolean; - tariffs?: string; - }): Promise => { + getTrafficUsage: async ( + params: TrafficParams, + options?: { skipCache?: boolean }, + ): Promise => { + const key = buildCacheKey(params); + + if (!options?.skipCache) { + const cached = trafficCache.get(key); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data; + } + } + const response = await apiClient.get('/cabinet/admin/traffic', { params }); - return response.data; + const data: TrafficUsageResponse = response.data; + + trafficCache.set(key, { data, timestamp: Date.now() }); + + return data; + }, + + getCached: (params: TrafficParams): TrafficUsageResponse | null => { + const key = buildCacheKey(params); + const cached = trafficCache.get(key); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data; + } + return null; + }, + + invalidateCache: () => { + trafficCache.clear(); }, exportCsv: async (data: { period: number }): Promise => { diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 90c4671..4d1179a 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -9,7 +9,12 @@ import { type SortingState, type RowData, } from '@tanstack/react-table'; -import { adminTrafficApi, type UserTrafficItem, type TrafficNodeInfo } from '../api/adminTraffic'; +import { + adminTrafficApi, + type UserTrafficItem, + type TrafficNodeInfo, + type TrafficParams, +} from '../api/adminTraffic'; import { usePlatform } from '../platform/hooks/usePlatform'; // ============ TanStack Table module augmentation ============ @@ -129,6 +134,53 @@ const ChevronDownIcon = () => ( ); +// ============ Progress Bar ============ + +function ProgressBar({ loading }: { loading: boolean }) { + const [progress, setProgress] = useState(0); + const [visible, setVisible] = useState(false); + const intervalRef = useRef>(undefined); + + useEffect(() => { + if (loading) { + setProgress(0); + setVisible(true); + // Fast initial progress, then slow down + intervalRef.current = setInterval(() => { + setProgress((prev) => { + if (prev < 30) return prev + 8; + if (prev < 60) return prev + 3; + if (prev < 85) return prev + 1; + if (prev < 95) return prev + 0.3; + return prev; + }); + }, 100); + } else { + if (visible) { + setProgress(100); + clearInterval(intervalRef.current); + const timer = setTimeout(() => { + setVisible(false); + setProgress(0); + }, 300); + return () => clearTimeout(timer); + } + } + return () => clearInterval(intervalRef.current); + }, [loading, visible]); + + if (!visible) return null; + + return ( +
+
+
+ ); +} + // ============ Components ============ const PERIODS = [1, 3, 7, 14, 30] as const; @@ -305,7 +357,8 @@ export default function AdminTrafficUsage() { const [items, setItems] = useState([]); const [nodes, setNodes] = useState([]); const [availableTariffs, setAvailableTariffs] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); const [period, setPeriod] = useState(30); const [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); @@ -318,38 +371,94 @@ export default function AdminTrafficUsage() { const [columnSizing, setColumnSizing] = useState>({}); const limit = 50; + const hasData = items.length > 0 || nodes.length > 0; const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes'; const sortDesc = sorting[0]?.desc ?? true; const tariffsParam = selectedTariffs.size > 0 ? [...selectedTariffs].join(',') : undefined; - const loadData = useCallback(async () => { - try { - setLoading(true); - const data = await adminTrafficApi.getTrafficUsage({ - period, - limit, - offset, - search: committedSearch || undefined, - sort_by: sortBy, - sort_desc: sortDesc, - tariffs: tariffsParam, - }); + const buildParams = useCallback( + (): TrafficParams => ({ + period, + limit, + offset, + search: committedSearch || undefined, + sort_by: sortBy, + sort_desc: sortDesc, + tariffs: tariffsParam, + }), + [period, offset, committedSearch, sortBy, sortDesc, tariffsParam], + ); + + const applyData = useCallback( + (data: { + items: UserTrafficItem[]; + nodes: TrafficNodeInfo[]; + total: number; + available_tariffs: string[]; + }) => { setItems(data.items); setNodes(data.nodes); setTotal(data.total); setAvailableTariffs(data.available_tariffs); - } catch { - // silently fail - } finally { - setLoading(false); - } - }, [period, offset, committedSearch, sortBy, sortDesc, tariffsParam]); + }, + [], + ); + const loadData = useCallback( + async (skipCache = false) => { + const params = buildParams(); + + // Check cache first — apply instantly without any loading state + if (!skipCache) { + const cached = adminTrafficApi.getCached(params); + if (cached) { + applyData(cached); + setInitialLoading(false); + return; + } + } + + try { + setLoading(true); + const data = await adminTrafficApi.getTrafficUsage(params, { skipCache }); + applyData(data); + } catch { + // silently fail — keep stale data visible + } finally { + setLoading(false); + setInitialLoading(false); + } + }, + [buildParams, applyData], + ); + + // Load on param change useEffect(() => { loadData(); }, [loadData]); + // Prefetch adjacent periods in background + useEffect(() => { + const prefetchPeriods = PERIODS.filter((p) => p !== period); + const timer = setTimeout(() => { + prefetchPeriods.forEach((p) => { + const params: TrafficParams = { + period: p, + limit, + offset: 0, + sort_by: 'total_bytes', + sort_desc: true, + }; + if (!adminTrafficApi.getCached(params)) { + adminTrafficApi.getTrafficUsage(params).catch(() => {}); + } + }); + }, 500); + return () => clearTimeout(timer); + // Only prefetch once on mount + when period changes + }, [period]); + useEffect(() => { if (toast) { const timer = setTimeout(() => setToast(null), 3000); @@ -391,6 +500,10 @@ export default function AdminTrafficUsage() { setOffset(0); }; + const handleRefresh = () => { + loadData(true); + }; + const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -509,7 +622,10 @@ export default function AdminTrafficUsage() { const currentPage = Math.floor(offset / limit) + 1; return ( -
+
+ {/* Progress bar — shown during background refresh */} + + {/* Toast */} {toast && (
{t('admin.trafficUsage.subtitle')}

-
@@ -584,83 +704,87 @@ export default function AdminTrafficUsage() {
{/* Table */} - {loading ? ( + {initialLoading && !hasData ? (
- ) : items.length === 0 ? ( + ) : !hasData && !loading ? (
{t('admin.trafficUsage.noData')}
) : ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const meta = header.column.columnDef.meta; - const isSticky = meta?.sticky; - const align = meta?.align === 'center' ? 'text-center' : 'text-left'; - const isBold = meta?.bold; +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta; + const isSticky = meta?.sticky; + const align = meta?.align === 'center' ? 'text-center' : 'text-left'; + const isBold = meta?.bold; - return ( - + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + navigate(`/admin/users/${row.original.user_id}`)} + > + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta; + const isSticky = meta?.sticky; + const align = meta?.align === 'center' ? 'text-center' : 'text-left'; + + return ( + - ))} - - - {table.getRowModel().rows.map((row) => ( - navigate(`/admin/users/${row.original.user_id}`)} - > - {row.getVisibleCells().map((cell) => { - const meta = cell.column.columnDef.meta; - const isSticky = meta?.sticky; - const align = meta?.align === 'center' ? 'text-center' : 'text-left'; - - return ( - - ); - })} - - ))} - -
- {flexRender(header.column.columnDef.header, header.getContext())} - {header.column.getCanSort() && ( - - )} -
e.stopPropagation()} - className={`absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${ - header.column.getIsResizing() - ? 'bg-accent-500' - : 'bg-transparent hover:bg-dark-500' + return ( +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() && ( + + )} +
e.stopPropagation()} + className={`absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${ + header.column.getIsResizing() + ? 'bg-accent-500' + : 'bg-transparent hover:bg-dark-500' + }`} + /> +
- - ); - })} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+ style={{ width: cell.column.getSize() }} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ))} + + +
)}