feat: local period calculation and refresh button for node usage

- Load panel + node data once on tab open (not per period change)
- Compute 7/14/30 day totals locally from cached daily data
- Add refresh button to subscription section
- No API calls when switching periods
This commit is contained in:
Fringg
2026-02-07 06:50:50 +03:00
parent 14b73f6db5
commit bc6985f522
2 changed files with 59 additions and 42 deletions

View File

@@ -134,10 +134,12 @@ export interface UserNodeUsageItem {
node_name: string; node_name: string;
country_code: string; country_code: string;
total_bytes: number; total_bytes: number;
daily_bytes: number[];
} }
export interface UserNodeUsageResponse { export interface UserNodeUsageResponse {
items: UserNodeUsageItem[]; items: UserNodeUsageItem[];
categories: string[];
period_days: number; period_days: number;
} }
@@ -545,11 +547,9 @@ export const adminUsersApi = {
return response.data; return response.data;
}, },
// Get node usage // Get node usage (always 30 days with daily breakdown)
getNodeUsage: async (userId: number, days = 7): Promise<UserNodeUsageResponse> => { getNodeUsage: async (userId: number): Promise<UserNodeUsageResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`, { const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
params: { days },
});
return response.data; return response.data;
}, },
}; };

View File

@@ -268,18 +268,19 @@ export default function AdminUserDetail() {
} }
}, [userId]); }, [userId]);
const loadNodeUsage = useCallback( const loadNodeUsage = useCallback(async () => {
async (days = 7) => { if (!userId) return;
if (!userId) return; try {
try { const data = await adminUsersApi.getNodeUsage(userId);
const data = await adminUsersApi.getNodeUsage(userId, days); setNodeUsage(data);
setNodeUsage(data); } catch {
} catch { // ignore
// ignore }
} }, [userId]);
},
[userId], const loadSubscriptionData = useCallback(async () => {
); await Promise.all([loadPanelInfo(), loadNodeUsage()]);
}, [loadPanelInfo, loadNodeUsage]);
const handleTicketReply = async () => { const handleTicketReply = async () => {
if (!selectedTicketId || !replyText.trim()) return; if (!selectedTicketId || !replyText.trim()) return;
@@ -335,16 +336,10 @@ export default function AdminUserDetail() {
if (activeTab === 'sync') loadSyncStatus(); if (activeTab === 'sync') loadSyncStatus();
if (activeTab === 'subscription') { if (activeTab === 'subscription') {
loadTariffs(); loadTariffs();
loadPanelInfo(); loadSubscriptionData();
} }
if (activeTab === 'tickets') loadTickets(); if (activeTab === 'tickets') loadTickets();
}, [activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadPanelInfo]); }, [activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadSubscriptionData]);
useEffect(() => {
if (activeTab === 'subscription') {
loadNodeUsage(nodeUsageDays);
}
}, [activeTab, loadNodeUsage, nodeUsageDays]);
const handleUpdateBalance = async (isAdd: boolean) => { const handleUpdateBalance = async (isAdd: boolean) => {
if (balanceAmount === '' || !userId) return; if (balanceAmount === '' || !userId) return;
@@ -547,6 +542,19 @@ export default function AdminUserDetail() {
}); });
}; };
// Compute node usage for selected period from cached 30-day data
const nodeUsageForPeriod = (() => {
if (!nodeUsage || nodeUsage.items.length === 0) return [];
return nodeUsage.items
.map((item) => {
const daily = item.daily_bytes || [];
const sliced = daily.slice(-nodeUsageDays);
const total = sliced.reduce((sum, v) => sum + v, 0);
return { ...item, total_bytes: total };
})
.sort((a, b) => b.total_bytes - a.total_bytes);
})();
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -1198,26 +1206,35 @@ export default function AdminUserDetail() {
<span className="text-sm font-medium text-dark-200"> <span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.nodeUsage')} {t('admin.users.detail.nodeUsage')}
</span> </span>
<div className="flex gap-1"> <div className="flex items-center gap-2">
{[7, 14, 30].map((d) => ( <div className="flex gap-1">
<button {[7, 14, 30].map((d) => (
key={d} <button
onClick={() => setNodeUsageDays(d)} key={d}
className={`rounded-lg px-2 py-1 text-xs transition-colors ${ onClick={() => setNodeUsageDays(d)}
nodeUsageDays === d className={`rounded-lg px-2 py-1 text-xs transition-colors ${
? 'bg-accent-500/20 text-accent-400' nodeUsageDays === d
: 'text-dark-500 hover:text-dark-300' ? 'bg-accent-500/20 text-accent-400'
}`} : 'text-dark-500 hover:text-dark-300'
> }`}
{d}d >
</button> {d}d
))} </button>
))}
</div>
<button
onClick={() => loadSubscriptionData()}
className="rounded-lg p-1 text-dark-500 transition-colors hover:text-dark-300"
title={t('common.refresh')}
>
<RefreshIcon className="h-3.5 w-3.5" />
</button>
</div> </div>
</div> </div>
{nodeUsage && nodeUsage.items.length > 0 ? ( {nodeUsageForPeriod.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{nodeUsage.items.map((item) => { {nodeUsageForPeriod.map((item) => {
const maxBytes = nodeUsage.items[0].total_bytes; const maxBytes = nodeUsageForPeriod[0].total_bytes;
const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0; const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0;
return ( return (
<div key={item.node_uuid}> <div key={item.node_uuid}>