diff --git a/src/api/adminRemnawave.ts b/src/api/adminRemnawave.ts index 0218421..c9e383b 100644 --- a/src/api/adminRemnawave.ts +++ b/src/api/adminRemnawave.ts @@ -21,12 +21,57 @@ export interface SystemSummary { total_users: number; active_connections: number; nodes_online: number; + total_nodes: number; users_last_day: number; users_last_week: number; users_never_online: number; total_user_traffic: number; } +// Panel recap / devices / top consumers +export interface RecapResponse { + version: string | null; + init_date: string | null; + total: { + users: number; + nodes: number; + traffic_bytes: number; + nodes_ram_bytes: number; + nodes_cpu_cores: number; + distinct_countries: number; + }; + this_month: { users: number; traffic_bytes: number }; +} + +export interface DevicesStatsResponse { + by_platform: { platform: string; count: number }[]; + by_app: { app: string; count: number }[]; + top_users: { username: string; devices_count: number }[]; + total_unique_devices: number; + total_hwid_devices: number; + average_devices_per_user: number; +} + +export interface TopConsumersResponse { + period_days: number; + users: { username: string; total_bytes: number }[]; +} + +export interface HealthResponse { + instances: number; + rss_bytes: number; + heap_used_bytes: number; + heap_total_bytes: number; + event_loop_delay_ms: number; + event_loop_p99_ms: number; + uptime_seconds: number; + instance_id: string | null; +} + +export interface SubscriptionRequestStatsResponse { + by_app: { app: string; count: number }[]; +} + export interface ServerInfo { cpu_cores: number; memory_total: number; @@ -292,6 +337,34 @@ export const adminRemnawaveApi = { return response.data; }, + // Panel recap / devices / top consumers + getRecap: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/recap'); + return response.data; + }, + + getDevicesStats: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/devices-stats'); + return response.data; + }, + + getTopConsumers: async (days = 7, limit = 10): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/top-consumers', { + params: { days, limit }, + }); + return response.data; + }, + + getHealth: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/health'); + return response.data; + }, + + getSubscriptionRequests: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/subscription-requests'); + return response.data; + }, + // Nodes getNodes: async (): Promise => { const response = await apiClient.get('/cabinet/admin/remnawave/nodes'); diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 4f040f6..a4e7d7f 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -9,6 +9,11 @@ import { SquadWithLocalInfo, SystemStatsResponse, AutoSyncStatus, + RecapResponse, + DevicesStatsResponse, + TopConsumersResponse, + HealthResponse, + SubscriptionRequestStatsResponse, } from '../api/adminRemnawave'; import { usePlatform } from '../platform/hooks/usePlatform'; import { formatUptime } from '../utils/format'; @@ -25,19 +30,19 @@ import { StopIcon, ArrowPathIcon, RemnawaveIcon, + DownloadIcon, + UploadIcon, + SubscriptionIcon, + BackIcon, + ChevronRightIcon, } from '../components/icons'; - -const BackIcon = () => ( - - - -); +import { + PiCpuDuotone, + PiMemoryDuotone, + PiTimerDuotone, + PiDevicesDuotone, + PiPulseDuotone, +} from 'react-icons/pi'; const formatBytes = (bytes: number): string => { if (bytes === 0) return '0 B'; @@ -231,15 +236,7 @@ function SquadCard({ squad, onClick }: SquadCardProps) { - - - + ); @@ -285,13 +282,67 @@ function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncC ); } +const formatUptimeSince = (iso: string): string => { + const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000); + if (Number.isNaN(days)) return '—'; + if (days < 1) return '<1d'; + if (days < 30) return `${days}d`; + if (days < 365) return `${Math.floor(days / 30)}mo`; + return `${Math.floor(days / 365)}y`; +}; + +function BreakdownCard({ + title, + items, +}: { + title: string; + items: { label: string; count: number }[]; +}) { + const max = Math.max(1, ...items.map((i) => i.count)); + return ( +
+

{title}

+
+ {items.slice(0, 8).map((it) => ( +
+
+ {it.label} + {it.count} +
+
+
+
+
+ ))} +
+
+ ); +} + interface OverviewTabProps { stats: SystemStatsResponse | undefined; + recap?: RecapResponse; + devicesStats?: DevicesStatsResponse; + topConsumers?: TopConsumersResponse; + health?: HealthResponse; + subRequests?: SubscriptionRequestStatsResponse; isLoading: boolean; onRefresh: () => void; } -function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { +function OverviewTab({ + stats, + recap, + devicesStats, + topConsumers, + health, + subRequests, + isLoading, + onRefresh, +}: OverviewTabProps) { const { t } = useTranslation(); if (isLoading) { @@ -341,13 +392,13 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { /> } - color="purple" + color={stats.system.nodes_online < stats.system.total_nodes ? 'orange' : 'purple'} /> } color="orange" /> @@ -356,26 +407,27 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {/* Bandwidth */}
-

+

+ {t('admin.remnawave.overview.bandwidth', 'Inbound Traffic')}

↓} + icon={} color="green" /> ↑} + icon={} color="blue" /> ⇅} + icon={} color="purple" />
@@ -383,27 +435,28 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {/* Server Info */}
-

+

+ {t('admin.remnawave.overview.server', 'Server')}

⚡} + icon={} color="accent" /> 💾} + icon={} color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'} /> ⏱️} + icon={} color="blue" />
@@ -411,38 +464,39 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {/* Traffic Periods */}
-

+

+ {t('admin.remnawave.overview.traffic', 'Traffic Statistics')}

📊} + icon={} color="accent" /> 📊} + icon={} color="blue" /> 📊} + icon={} color="green" /> 📊} + icon={} color="purple" /> 📊} + icon={} color="orange" />
@@ -450,7 +504,8 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {/* Users by Status */}
-

+

+ {t('admin.remnawave.overview.usersByStatus', 'Users by Status')}

@@ -465,6 +520,160 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { ))}
+ + {/* Panel recap */} + {recap && ( +
+

+ + {t('admin.remnawave.overview.panel', 'Панель')} +

+
+ } + color="purple" + /> + } + color="blue" + /> + } + color="green" + /> + } + color="accent" + /> +
+
+ )} + + {/* Devices breakdown */} + {devicesStats && (devicesStats.by_platform.length > 0 || devicesStats.by_app.length > 0) && ( +
+

+ + {t('admin.remnawave.overview.devices', 'Устройства')} ·{' '} + {devicesStats.total_hwid_devices} ({devicesStats.average_devices_per_user.toFixed(1)}/ + {t('admin.remnawave.overview.perUser', 'юзер')}) +

+
+ ({ label: p.platform, count: p.count }))} + /> + ({ label: a.app, count: a.count }))} + /> + {devicesStats.top_users.length > 0 && ( + ({ + label: u.username, + count: u.devices_count, + }))} + /> + )} +
+
+ )} + + {/* Top consumers */} + {topConsumers && topConsumers.users.length > 0 && ( +
+

+ + {t('admin.remnawave.overview.topConsumers', 'Топ потребителей')} ·{' '} + {topConsumers.period_days} + {t('admin.remnawave.overview.daysShort', 'д')} +

+
+ {topConsumers.users.map((u, i) => ( +
+ + {i + 1} + {u.username} + + + {formatBytes(u.total_bytes)} + +
+ ))} +
+
+ )} + + {/* Panel health */} + {health && health.instances > 0 && ( +
+

+ + {t('admin.remnawave.overview.panelHealth', 'Здоровье панели')} + {health.instances > 1 ? ` · ${health.instances}` : ''} +

+
+ } + color="blue" + /> + } + color="purple" + /> + } + color={health.event_loop_p99_ms > 50 ? 'red' : 'green'} + /> + } + color="accent" + /> +
+
+ )} + + {/* Subscription requests by app */} + {subRequests && subRequests.by_app.length > 0 && ( +
+

+ + {t('admin.remnawave.overview.subRequests', 'Запросы подписки (по клиентам)')} +

+
+ ({ label: a.app, count: a.count }))} + /> +
+
+ )}
); } @@ -857,19 +1066,19 @@ function TrafficTab({ data, isLoading, onRefresh }: TrafficTabProps) { ↓} + icon={} color="green" /> ↑} + icon={} color="blue" /> ⇅} + icon={} color="purple" />
@@ -980,6 +1189,42 @@ export default function AdminRemnawave() { refetchInterval: 30000, }); + const { data: recap } = useQuery({ + queryKey: ['admin-remnawave-recap'], + queryFn: adminRemnawaveApi.getRecap, + enabled: activeTab === 'overview', + staleTime: 60000, + }); + + const { data: devicesStats } = useQuery({ + queryKey: ['admin-remnawave-devices-stats'], + queryFn: adminRemnawaveApi.getDevicesStats, + enabled: activeTab === 'overview', + staleTime: 60000, + }); + + const { data: topConsumers } = useQuery({ + queryKey: ['admin-remnawave-top-consumers'], + queryFn: () => adminRemnawaveApi.getTopConsumers(7, 10), + enabled: activeTab === 'overview', + staleTime: 60000, + }); + + const { data: health } = useQuery({ + queryKey: ['admin-remnawave-health'], + queryFn: adminRemnawaveApi.getHealth, + enabled: activeTab === 'overview', + refetchInterval: 30000, + staleTime: 15000, + }); + + const { data: subRequests } = useQuery({ + queryKey: ['admin-remnawave-sub-requests'], + queryFn: adminRemnawaveApi.getSubscriptionRequests, + enabled: activeTab === 'overview', + staleTime: 60000, + }); + const { data: nodesData, isLoading: isLoadingNodes, @@ -1112,7 +1357,7 @@ export default function AdminRemnawave() { onClick={() => navigate('/admin')} className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600" > - + )}
@@ -1172,6 +1417,11 @@ export default function AdminRemnawave() { {activeTab === 'overview' && ( refetchStats()} />