mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Merge pull request #184 from BEDOLAGA-DEV/fix/traffic-smooth-loading
fix: client-side caching and smooth loading for traffic page
This commit is contained in:
@@ -34,18 +34,65 @@ export interface ExportCsvResponse {
|
|||||||
message: string;
|
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 = {
|
export const adminTrafficApi = {
|
||||||
getTrafficUsage: async (params: {
|
getTrafficUsage: async (
|
||||||
period?: number;
|
params: TrafficParams,
|
||||||
limit?: number;
|
options?: { skipCache?: boolean },
|
||||||
offset?: number;
|
): Promise<TrafficUsageResponse> => {
|
||||||
search?: string;
|
const key = buildCacheKey(params);
|
||||||
sort_by?: string;
|
|
||||||
sort_desc?: boolean;
|
if (!options?.skipCache) {
|
||||||
tariffs?: string;
|
const cached = trafficCache.get(key);
|
||||||
}): Promise<TrafficUsageResponse> => {
|
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> => {
|
||||||
|
|||||||
@@ -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,38 +371,94 @@ 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);
|
period,
|
||||||
const data = await adminTrafficApi.getTrafficUsage({
|
limit,
|
||||||
period,
|
offset,
|
||||||
limit,
|
search: committedSearch || undefined,
|
||||||
offset,
|
sort_by: sortBy,
|
||||||
search: committedSearch || undefined,
|
sort_desc: sortDesc,
|
||||||
sort_by: sortBy,
|
tariffs: tariffsParam,
|
||||||
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);
|
setItems(data.items);
|
||||||
setNodes(data.nodes);
|
setNodes(data.nodes);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
setAvailableTariffs(data.available_tariffs);
|
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(() => {
|
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,83 +704,87 @@ 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="overflow-x-auto rounded-xl border border-dark-700">
|
<div
|
||||||
<table className="text-left text-sm" style={{ width: table.getCenterTotalSize() }}>
|
className={`transition-opacity duration-200 ${loading && hasData ? 'opacity-70' : 'opacity-100'}`}
|
||||||
<thead>
|
>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<div className="overflow-x-auto rounded-xl border border-dark-700">
|
||||||
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
|
<table className="text-left text-sm" style={{ width: table.getCenterTotalSize() }}>
|
||||||
{headerGroup.headers.map((header) => {
|
<thead>
|
||||||
const meta = header.column.columnDef.meta;
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
const isSticky = meta?.sticky;
|
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
|
||||||
const align = meta?.align === 'center' ? 'text-center' : 'text-left';
|
{headerGroup.headers.map((header) => {
|
||||||
const isBold = meta?.bold;
|
const meta = header.column.columnDef.meta;
|
||||||
|
const isSticky = meta?.sticky;
|
||||||
|
const align = meta?.align === 'center' ? 'text-center' : 'text-left';
|
||||||
|
const isBold = meta?.bold;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className={`relative px-3 py-2 text-xs font-medium ${
|
className={`relative px-3 py-2 text-xs font-medium ${
|
||||||
isBold ? 'font-semibold text-dark-200' : 'text-dark-400'
|
isBold ? 'font-semibold text-dark-200' : 'text-dark-400'
|
||||||
} ${align} ${
|
} ${align} ${
|
||||||
isSticky ? 'sticky left-0 z-10 bg-dark-800' : ''
|
isSticky ? 'sticky left-0 z-10 bg-dark-800' : ''
|
||||||
} ${header.column.getCanSort() ? 'cursor-pointer select-none hover:text-dark-200' : ''}`}
|
} ${header.column.getCanSort() ? 'cursor-pointer select-none hover:text-dark-200' : ''}`}
|
||||||
style={{ width: header.getSize() }}
|
style={{ width: header.getSize() }}
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
>
|
>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
{header.column.getCanSort() && (
|
{header.column.getCanSort() && (
|
||||||
<SortIcon direction={header.column.getIsSorted()} />
|
<SortIcon direction={header.column.getIsSorted()} />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
onMouseDown={header.getResizeHandler()}
|
onMouseDown={header.getResizeHandler()}
|
||||||
onTouchStart={header.getResizeHandler()}
|
onTouchStart={header.getResizeHandler()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${
|
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${
|
||||||
header.column.getIsResizing()
|
header.column.getIsResizing()
|
||||||
? 'bg-accent-500'
|
? 'bg-accent-500'
|
||||||
: 'bg-transparent hover:bg-dark-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' : ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
style={{ width: cell.column.getSize() }}
|
||||||
</th>
|
>
|
||||||
);
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
})}
|
</td>
|
||||||
</tr>
|
);
|
||||||
))}
|
})}
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
))}
|
||||||
{table.getRowModel().rows.map((row) => (
|
</tbody>
|
||||||
<tr
|
</table>
|
||||||
key={row.id}
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user