diff --git a/package-lock.json b/package-lock.json index e2d03b0..da16049 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", + "@tanstack/react-table": "^8.21.3", "@telegram-apps/sdk-react": "^3.3.9", "autoprefixer": "^10.4.24", "axios": "^1.6.0", @@ -2626,6 +2627,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@telegram-apps/bridge": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-2.11.0.tgz", diff --git a/package.json b/package.json index 99093a2..262d056 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", + "@tanstack/react-table": "^8.21.3", "@telegram-apps/sdk-react": "^3.3.9", "autoprefixer": "^10.4.24", "axios": "^1.6.0", diff --git a/src/App.tsx b/src/App.tsx index 58bdcaa..0e5031a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,6 +69,7 @@ const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend')); const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')); +const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); @@ -565,6 +566,16 @@ function App() { } /> + + + + + + } + /> ; + total_bytes: number; +} + +export interface TrafficUsageResponse { + items: UserTrafficItem[]; + nodes: TrafficNodeInfo[]; + total: number; + offset: number; + limit: number; + period_days: number; +} + +export interface ExportCsvResponse { + success: boolean; + message: string; +} + +export const adminTrafficApi = { + getTrafficUsage: async (params: { + period?: number; + limit?: number; + offset?: number; + search?: string; + sort_by?: string; + sort_desc?: boolean; + }): Promise => { + const response = await apiClient.get('/cabinet/admin/traffic', { params }); + return response.data; + }, + + exportCsv: async (data: { period: number }): Promise => { + const response = await apiClient.post('/cabinet/admin/traffic/export-csv', data); + return response.data; + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index 62ac714..86a9396 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -761,7 +761,8 @@ "campaigns": "Campaigns", "promoOffers": "Promo Offers", "promocodes": "Promo Codes", - "promoGroups": "Discount Groups" + "promoGroups": "Discount Groups", + "trafficUsage": "Traffic Usage" }, "panel": { "title": "Admin Panel", @@ -783,7 +784,26 @@ "campaignsDesc": "Advertising campaigns", "promoOffersDesc": "Personal discounts", "promocodesDesc": "Manage promo codes", - "promoGroupsDesc": "Discount groups for users" + "promoGroupsDesc": "Discount groups for users", + "trafficUsageDesc": "Per-user traffic by nodes" + }, + "trafficUsage": { + "title": "Traffic Usage", + "subtitle": "Per-user traffic statistics by nodes", + "period": "Period", + "days": "d", + "exportCsv": "Export CSV", + "exportSuccess": "CSV sent to your Telegram", + "exportError": "Failed to export", + "user": "User", + "tariff": "Tariff", + "devices": "Devices", + "trafficLimit": "Limit", + "total": "Total", + "noData": "No traffic data", + "loading": "Loading traffic data...", + "noTariff": "\u2014", + "search": "Search by name or username" }, "emailTemplates": { "title": "Email Templates", diff --git a/src/locales/fa.json b/src/locales/fa.json index 06eae91..cd7f032 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -642,7 +642,8 @@ "promocodes": "کدهای تخفیف", "promoGroups": "گروه‌های تخفیف", "remnawave": "RemnaWave", - "users": "کاربران" + "users": "کاربران", + "trafficUsage": "مصرف ترافیک" }, "panel": { "title": "پنل مدیریت", @@ -664,7 +665,26 @@ "promocodesDesc": "مدیریت کدهای تخفیف", "promoGroupsDesc": "گروه‌های تخفیف برای کاربران", "remnawaveDesc": "مدیریت پنل و آمار", - "usersDesc": "مدیریت کاربران ربات" + "usersDesc": "مدیریت کاربران ربات", + "trafficUsageDesc": "ترافیک هر کاربر بر اساس نود" + }, + "trafficUsage": { + "title": "مصرف ترافیک", + "subtitle": "آمار ترافیک کاربران بر اساس نودها", + "period": "دوره", + "days": "روز", + "exportCsv": "خروجی CSV", + "exportSuccess": "CSV به تلگرام شما ارسال شد", + "exportError": "خطا در خروجی", + "user": "کاربر", + "tariff": "تعرفه", + "devices": "دستگاه‌ها", + "trafficLimit": "محدودیت", + "total": "کل", + "noData": "داده ترافیکی موجود نیست", + "loading": "در حال بارگذاری داده‌های ترافیک...", + "noTariff": "\u2014", + "search": "جستجو بر اساس نام یا نام کاربری" }, "emailTemplates": { "title": "قالب‌های ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2d3654b..21a1147 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -782,7 +782,8 @@ "campaigns": "Кампании", "promoOffers": "Промопредложения", "promocodes": "Промокоды", - "promoGroups": "Группы скидок" + "promoGroups": "Группы скидок", + "trafficUsage": "Расход трафика" }, "panel": { "title": "Панель администратора", @@ -804,7 +805,26 @@ "campaignsDesc": "Рекламные кампании", "promoOffersDesc": "Персональные скидки", "promocodesDesc": "Управление промокодами", - "promoGroupsDesc": "Группы скидок для пользователей" + "promoGroupsDesc": "Группы скидок для пользователей", + "trafficUsageDesc": "Трафик пользователей по нодам" + }, + "trafficUsage": { + "title": "Расход трафика", + "subtitle": "Статистика трафика по пользователям и нодам", + "period": "Период", + "days": "д", + "exportCsv": "Экспорт CSV", + "exportSuccess": "CSV отправлен в ваш Telegram", + "exportError": "Ошибка экспорта", + "user": "Пользователь", + "tariff": "Тариф", + "devices": "Устройства", + "trafficLimit": "Лимит", + "total": "Всего", + "noData": "Нет данных о трафике", + "loading": "Загрузка данных о трафике...", + "noTariff": "\u2014", + "search": "Поиск по имени или юзернейму" }, "emailTemplates": { "title": "Email-шаблоны", diff --git a/src/locales/zh.json b/src/locales/zh.json index 6d8bd78..cbff87b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -642,7 +642,8 @@ "promocodes": "促销码", "promoGroups": "折扣组", "remnawave": "RemnaWave", - "users": "用户" + "users": "用户", + "trafficUsage": "流量使用" }, "panel": { "title": "管理面板", @@ -664,7 +665,26 @@ "promocodesDesc": "管理促销码", "promoGroupsDesc": "用户折扣组", "remnawaveDesc": "面板管理和统计", - "usersDesc": "管理机器人用户" + "usersDesc": "管理机器人用户", + "trafficUsageDesc": "按节点统计用户流量" + }, + "trafficUsage": { + "title": "流量使用", + "subtitle": "按节点的用户流量统计", + "period": "周期", + "days": "天", + "exportCsv": "导出CSV", + "exportSuccess": "CSV已发送到您的Telegram", + "exportError": "导出失败", + "user": "用户", + "tariff": "套餐", + "devices": "设备", + "trafficLimit": "限制", + "total": "总计", + "noData": "无流量数据", + "loading": "正在加载流量数据...", + "noTariff": "\u2014", + "search": "按名称或用户名搜索" }, "paymentMethods": { "title": "支付方法", diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 2352470..ff86da8 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -216,6 +216,16 @@ const EnvelopeIcon = () => ( ); +const ArrowsUpDownIcon = () => ( + + + +); + const ChevronRightIcon = () => ( @@ -317,6 +327,12 @@ export default function AdminPanel() { title: t('admin.nav.payments'), description: t('admin.panel.paymentsDesc'), }, + { + to: '/admin/traffic-usage', + icon: , + title: t('admin.nav.trafficUsage'), + description: t('admin.panel.trafficUsageDesc'), + }, ], }, { diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx new file mode 100644 index 0000000..8d1198a --- /dev/null +++ b/src/pages/AdminTrafficUsage.tsx @@ -0,0 +1,513 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { + useReactTable, + getCoreRowModel, + flexRender, + type ColumnDef, + type SortingState, + type RowData, +} from '@tanstack/react-table'; +import { adminTrafficApi, type UserTrafficItem, type TrafficNodeInfo } from '../api/adminTraffic'; +import { usePlatform } from '../platform/hooks/usePlatform'; + +// ============ TanStack Table module augmentation ============ + +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + sticky?: boolean; + align?: 'left' | 'center'; + bold?: boolean; + } +} + +// ============ Utils ============ + +const formatBytes = (bytes: number): string => { + if (bytes <= 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +const getFlagEmoji = (countryCode: string): string => { + if (!countryCode || countryCode.length !== 2) return ''; + const codePoints = countryCode + .toUpperCase() + .split('') + .map((char) => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; + +/** Map TanStack column IDs to backend sort_by field names. */ +const toBackendSortField = (columnId: string): string => { + if (columnId === 'user') return 'full_name'; + return columnId; +}; + +// ============ Icons ============ + +const SearchIcon = () => ( + + + +); + +const ChevronLeftIcon = () => ( + + + +); + +const ChevronRightIcon = () => ( + + + +); + +const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( + + + +); + +const DownloadIcon = () => ( + + + +); + +const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => ( + + {direction === 'asc' ? ( + + ) : direction === 'desc' ? ( + + ) : ( + + )} + +); + +// ============ Components ============ + +const PERIODS = [1, 3, 7, 14, 30] as const; + +function PeriodSelector({ + value, + onChange, + label, +}: { + value: number; + onChange: (v: number) => void; + label: string; +}) { + const { t } = useTranslation(); + return ( +
+ {label} +
+ {PERIODS.map((p) => ( + + ))} +
+
+ ); +} + +// ============ Main Page ============ + +export default function AdminTrafficUsage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { capabilities } = usePlatform(); + + const [items, setItems] = useState([]); + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [period, setPeriod] = useState(30); + const [searchInput, setSearchInput] = useState(''); + const [committedSearch, setCommittedSearch] = useState(''); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [exporting, setExporting] = useState(false); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + const [sorting, setSorting] = useState([{ id: 'total_bytes', desc: true }]); + + const limit = 50; + + const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes'; + const sortDesc = sorting[0]?.desc ?? true; + + const loadData = useCallback(async () => { + try { + setLoading(true); + const data = await adminTrafficApi.getTrafficUsage({ + period, + limit, + offset, + search: committedSearch || undefined, + sort_by: sortBy, + sort_desc: sortDesc, + }); + setItems(data.items); + setNodes(data.nodes); + setTotal(data.total); + } catch { + // silently fail — toast could be added if needed + } finally { + setLoading(false); + } + }, [period, offset, committedSearch, sortBy, sortDesc]); + + useEffect(() => { + loadData(); + }, [loadData]); + + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setOffset(0); + setCommittedSearch(searchInput); + }; + + const handleExport = async () => { + try { + setExporting(true); + await adminTrafficApi.exportCsv({ period }); + setToast({ message: t('admin.trafficUsage.exportSuccess'), type: 'success' }); + } catch { + setToast({ message: t('admin.trafficUsage.exportError'), type: 'error' }); + } finally { + setExporting(false); + } + }; + + const handlePeriodChange = (p: number) => { + setPeriod(p); + setOffset(0); + }; + + const handleSortingChange = (updater: SortingState | ((old: SortingState) => SortingState)) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + setSorting(next); + setOffset(0); + }; + + // Build columns dynamically based on nodes + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = [ + { + id: 'user', + accessorFn: (row) => row.full_name, + header: t('admin.trafficUsage.user'), + enableSorting: true, + cell: ({ row }) => { + const item = row.original; + return ( +
+
+ {item.full_name?.[0] || '?'} +
+
+
{item.full_name}
+ {item.username && ( +
@{item.username}
+ )} +
+
+ ); + }, + meta: { sticky: true }, + }, + { + accessorKey: 'tariff_name', + header: t('admin.trafficUsage.tariff'), + enableSorting: true, + cell: ({ getValue }) => ( + + {(getValue() as string | null) || t('admin.trafficUsage.noTariff')} + + ), + }, + { + accessorKey: 'device_limit', + header: t('admin.trafficUsage.devices'), + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ getValue }) => ( + {getValue() as number} + ), + }, + { + accessorKey: 'traffic_limit_gb', + header: t('admin.trafficUsage.trafficLimit'), + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ getValue }) => { + const gb = getValue() as number; + return {gb > 0 ? `${gb} GB` : '\u221E'}; + }, + }, + // Dynamic node columns + ...nodes.map( + (node): ColumnDef => ({ + id: `node_${node.node_uuid}`, + accessorFn: (row) => row.node_traffic[node.node_uuid] || 0, + header: `${getFlagEmoji(node.country_code)} ${node.node_name}`, + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ getValue }) => { + const bytes = getValue() as number; + return ( + + {bytes > 0 ? formatBytes(bytes) : '\u2014'} + + ); + }, + }), + ), + { + accessorKey: 'total_bytes', + header: t('admin.trafficUsage.total'), + enableSorting: true, + meta: { align: 'center' as const, bold: true }, + cell: ({ getValue }) => { + const bytes = getValue() as number; + return ( + + {bytes > 0 ? formatBytes(bytes) : '\u2014'} + + ); + }, + }, + ]; + return cols; + }, [nodes, t]); + + const table = useReactTable({ + data: items, + columns, + state: { sorting }, + onSortingChange: handleSortingChange, + getCoreRowModel: getCoreRowModel(), + manualSorting: true, + enableSortingRemoval: false, + }); + + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + return ( +
+ {/* Toast */} + {toast && ( +
+ {toast.message} +
+ )} + + {/* Header */} +
+
+ {!capabilities.hasBackButton && ( + + )} +
+

{t('admin.trafficUsage.title')}

+

{t('admin.trafficUsage.subtitle')}

+
+
+ +
+ + {/* Controls: period + export + search */} +
+
+ + +
+ +
+
+ setSearchInput(e.target.value)} + placeholder={t('admin.trafficUsage.search')} + className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-4 text-dark-100 placeholder-dark-500 focus:border-dark-600 focus:outline-none" + /> +
+ +
+
+
+
+ + {/* Table */} + {loading ? ( +
+
+
+ ) : items.length === 0 ? ( +
{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; + + 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() && ( + + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ {offset + 1} + {'\u2013'} + {Math.min(offset + limit, total)} / {total} +
+
+ + + {currentPage} / {totalPages} + + +
+
+ )} +
+ ); +}