mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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<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: {
|
||||
period?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_desc?: boolean;
|
||||
tariffs?: string;
|
||||
}): Promise<TrafficUsageResponse> => {
|
||||
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 });
|
||||
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> => {
|
||||
|
||||
@@ -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 = () => (
|
||||
</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 ============
|
||||
|
||||
const PERIODS = [1, 3, 7, 14, 30] as const;
|
||||
@@ -305,7 +357,8 @@ export default function AdminTrafficUsage() {
|
||||
const [items, setItems] = useState<UserTrafficItem[]>([]);
|
||||
const [nodes, setNodes] = useState<TrafficNodeInfo[]>([]);
|
||||
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 [searchInput, setSearchInput] = useState('');
|
||||
const [committedSearch, setCommittedSearch] = useState('');
|
||||
@@ -318,38 +371,94 @@ export default function AdminTrafficUsage() {
|
||||
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
|
||||
|
||||
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<ColumnDef<UserTrafficItem>[]>(() => {
|
||||
const cols: ColumnDef<UserTrafficItem>[] = [
|
||||
{
|
||||
@@ -509,7 +622,10 @@ export default function AdminTrafficUsage() {
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="relative animate-fade-in">
|
||||
{/* Progress bar — shown during background refresh */}
|
||||
<ProgressBar loading={loading} />
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div
|
||||
@@ -539,7 +655,11 @@ export default function AdminTrafficUsage() {
|
||||
<p className="text-sm text-dark-400">{t('admin.trafficUsage.subtitle')}</p>
|
||||
</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' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -584,83 +704,87 @@ export default function AdminTrafficUsage() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
{initialLoading && !hasData ? (
|
||||
<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>
|
||||
) : items.length === 0 ? (
|
||||
) : !hasData && !loading ? (
|
||||
<div className="py-12 text-center text-dark-400">{t('admin.trafficUsage.noData')}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-dark-700">
|
||||
<table className="text-left text-sm" style={{ width: table.getCenterTotalSize() }}>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
|
||||
{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;
|
||||
<div
|
||||
className={`transition-opacity duration-200 ${loading && hasData ? 'opacity-70' : 'opacity-100'}`}
|
||||
>
|
||||
<div className="overflow-x-auto rounded-xl border border-dark-700">
|
||||
<table className="text-left text-sm" style={{ width: table.getCenterTotalSize() }}>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
|
||||
{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 (
|
||||
<th
|
||||
key={header.id}
|
||||
className={`relative px-3 py-2 text-xs font-medium ${
|
||||
isBold ? 'font-semibold text-dark-200' : 'text-dark-400'
|
||||
} ${align} ${
|
||||
isSticky ? 'sticky left-0 z-10 bg-dark-800' : ''
|
||||
} ${header.column.getCanSort() ? 'cursor-pointer select-none hover:text-dark-200' : ''}`}
|
||||
style={{ width: header.getSize() }}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getCanSort() && (
|
||||
<SortIcon direction={header.column.getIsSorted()} />
|
||||
)}
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onClick={(e) => 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 (
|
||||
<th
|
||||
key={header.id}
|
||||
className={`relative px-3 py-2 text-xs font-medium ${
|
||||
isBold ? 'font-semibold text-dark-200' : 'text-dark-400'
|
||||
} ${align} ${
|
||||
isSticky ? 'sticky left-0 z-10 bg-dark-800' : ''
|
||||
} ${header.column.getCanSort() ? 'cursor-pointer select-none hover:text-dark-200' : ''}`}
|
||||
style={{ width: header.getSize() }}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getCanSort() && (
|
||||
<SortIcon direction={header.column.getIsSorted()} />
|
||||
)}
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onClick={(e) => 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'
|
||||
}`}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="cursor-pointer border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
|
||||
onClick={() => 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 (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={`px-3 py-2 ${align} ${
|
||||
isSticky ? 'sticky left-0 z-10 bg-dark-900' : ''
|
||||
}`}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="cursor-pointer border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
|
||||
onClick={() => 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 (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={`px-3 py-2 ${align} ${
|
||||
isSticky ? 'sticky left-0 z-10 bg-dark-900' : ''
|
||||
}`}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user