fix: add client-side caching and smooth loading for traffic page

- Add 5-min in-memory cache in API layer keyed by request params
- Replace blocking spinner with animated progress bar during refresh
- Keep stale data visible while fetching (opacity dim)
- Prefetch all periods in background for instant switching
This commit is contained in:
Fringg
2026-02-07 09:47:02 +03:00
parent cfb7ce72f2
commit 471c37b7b3
2 changed files with 273 additions and 102 deletions

View File

@@ -34,8 +34,7 @@ export interface ExportCsvResponse {
message: string; message: string;
} }
export const adminTrafficApi = { export type TrafficParams = {
getTrafficUsage: async (params: {
period?: number; period?: number;
limit?: number; limit?: number;
offset?: number; offset?: number;
@@ -43,9 +42,57 @@ export const adminTrafficApi = {
sort_by?: string; sort_by?: string;
sort_desc?: boolean; sort_desc?: boolean;
tariffs?: string; tariffs?: string;
}): Promise<TrafficUsageResponse> => { };
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>();
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: TrafficParams,
options?: { skipCache?: boolean },
): Promise<TrafficUsageResponse> => {
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 }); 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<ExportCsvResponse> => { exportCsv: async (data: { period: number }): Promise<ExportCsvResponse> => {

View File

@@ -9,7 +9,12 @@ import {
type SortingState, type SortingState,
type RowData, type RowData,
} from '@tanstack/react-table'; } 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'; import { usePlatform } from '../platform/hooks/usePlatform';
// ============ TanStack Table module augmentation ============ // ============ TanStack Table module augmentation ============
@@ -129,6 +134,53 @@ const ChevronDownIcon = () => (
</svg> </svg>
); );
// ============ Progress Bar ============
function ProgressBar({ loading }: { loading: boolean }) {
const [progress, setProgress] = useState(0);
const [visible, setVisible] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval>>(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 (
<div className="absolute left-0 right-0 top-0 z-50 h-0.5 overflow-hidden rounded-full bg-dark-700/50">
<div
className="h-full rounded-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-200 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
);
}
// ============ Components ============ // ============ Components ============
const PERIODS = [1, 3, 7, 14, 30] as const; const PERIODS = [1, 3, 7, 14, 30] as const;
@@ -305,7 +357,8 @@ export default function AdminTrafficUsage() {
const [items, setItems] = useState<UserTrafficItem[]>([]); const [items, setItems] = useState<UserTrafficItem[]>([]);
const [nodes, setNodes] = useState<TrafficNodeInfo[]>([]); const [nodes, setNodes] = useState<TrafficNodeInfo[]>([]);
const [availableTariffs, setAvailableTariffs] = useState<string[]>([]); const [availableTariffs, setAvailableTariffs] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [period, setPeriod] = useState(30); const [period, setPeriod] = useState(30);
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [committedSearch, setCommittedSearch] = useState(''); const [committedSearch, setCommittedSearch] = useState('');
@@ -318,15 +371,14 @@ export default function AdminTrafficUsage() {
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({}); const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
const limit = 50; const limit = 50;
const hasData = items.length > 0 || nodes.length > 0;
const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes'; const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes';
const sortDesc = sorting[0]?.desc ?? true; const sortDesc = sorting[0]?.desc ?? true;
const tariffsParam = selectedTariffs.size > 0 ? [...selectedTariffs].join(',') : undefined; const tariffsParam = selectedTariffs.size > 0 ? [...selectedTariffs].join(',') : undefined;
const loadData = useCallback(async () => { const buildParams = useCallback(
try { (): TrafficParams => ({
setLoading(true);
const data = await adminTrafficApi.getTrafficUsage({
period, period,
limit, limit,
offset, offset,
@@ -334,22 +386,79 @@ export default function AdminTrafficUsage() {
sort_by: sortBy, sort_by: sortBy,
sort_desc: sortDesc, sort_desc: sortDesc,
tariffs: tariffsParam, tariffs: tariffsParam,
}); }),
[period, offset, committedSearch, sortBy, sortDesc, tariffsParam],
);
const applyData = useCallback(
(data: {
items: UserTrafficItem[];
nodes: TrafficNodeInfo[];
total: number;
available_tariffs: string[];
}) => {
setItems(data.items); setItems(data.items);
setNodes(data.nodes); setNodes(data.nodes);
setTotal(data.total); setTotal(data.total);
setAvailableTariffs(data.available_tariffs); setAvailableTariffs(data.available_tariffs);
},
[],
);
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 { } catch {
// silently fail // silently fail — keep stale data visible
} finally { } finally {
setLoading(false); setLoading(false);
setInitialLoading(false);
} }
}, [period, offset, committedSearch, sortBy, sortDesc, tariffsParam]); },
[buildParams, applyData],
);
// Load on param change
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [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(() => { useEffect(() => {
if (toast) { if (toast) {
const timer = setTimeout(() => setToast(null), 3000); const timer = setTimeout(() => setToast(null), 3000);
@@ -391,6 +500,10 @@ export default function AdminTrafficUsage() {
setOffset(0); setOffset(0);
}; };
const handleRefresh = () => {
loadData(true);
};
const columns = useMemo<ColumnDef<UserTrafficItem>[]>(() => { const columns = useMemo<ColumnDef<UserTrafficItem>[]>(() => {
const cols: ColumnDef<UserTrafficItem>[] = [ const cols: ColumnDef<UserTrafficItem>[] = [
{ {
@@ -509,7 +622,10 @@ export default function AdminTrafficUsage() {
const currentPage = Math.floor(offset / limit) + 1; const currentPage = Math.floor(offset / limit) + 1;
return ( return (
<div className="animate-fade-in"> <div className="relative animate-fade-in">
{/* Progress bar — shown during background refresh */}
<ProgressBar loading={loading} />
{/* Toast */} {/* Toast */}
{toast && ( {toast && (
<div <div
@@ -539,7 +655,11 @@ export default function AdminTrafficUsage() {
<p className="text-sm text-dark-400">{t('admin.trafficUsage.subtitle')}</p> <p className="text-sm text-dark-400">{t('admin.trafficUsage.subtitle')}</p>
</div> </div>
</div> </div>
<button onClick={loadData} className="rounded-lg p-2 transition-colors hover:bg-dark-700"> <button
onClick={handleRefresh}
disabled={loading}
className="rounded-lg p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
>
<RefreshIcon className={loading ? 'animate-spin' : ''} /> <RefreshIcon className={loading ? 'animate-spin' : ''} />
</button> </button>
</div> </div>
@@ -584,13 +704,16 @@ export default function AdminTrafficUsage() {
</div> </div>
{/* Table */} {/* Table */}
{loading ? ( {initialLoading && !hasData ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
) : items.length === 0 ? ( ) : !hasData && !loading ? (
<div className="py-12 text-center text-dark-400">{t('admin.trafficUsage.noData')}</div> <div className="py-12 text-center text-dark-400">{t('admin.trafficUsage.noData')}</div>
) : ( ) : (
<div
className={`transition-opacity duration-200 ${loading && hasData ? 'opacity-70' : 'opacity-100'}`}
>
<div className="overflow-x-auto rounded-xl border border-dark-700"> <div className="overflow-x-auto rounded-xl border border-dark-700">
<table className="text-left text-sm" style={{ width: table.getCenterTotalSize() }}> <table className="text-left text-sm" style={{ width: table.getCenterTotalSize() }}>
<thead> <thead>
@@ -662,6 +785,7 @@ export default function AdminTrafficUsage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)} )}
{/* Pagination */} {/* Pagination */}