From 5cdae2450a371f9fc4fa5b4132d05753e34d7cb2 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 10:51:04 +0300 Subject: [PATCH] Add files via upload --- src/pages/AdminPanel.tsx | 16 + src/pages/AdminRemnawave.tsx | 1090 ++++++++++++++++++++++++++++++++++ 2 files changed, 1106 insertions(+) create mode 100644 src/pages/AdminRemnawave.tsx diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 58aa047..0cb5fa7 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -87,6 +87,12 @@ const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( ) +const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + const ChevronRightIcon = () => ( @@ -276,6 +282,16 @@ export default function AdminPanel() { bgColor: 'bg-lime-500/20', textColor: 'text-lime-400' }, + { + to: '/admin/remnawave', + icon: , + mobileIcon: , + title: t('admin.nav.remnawave', 'RemnaWave'), + description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'), + color: 'purple', + bgColor: 'bg-purple-500/20', + textColor: 'text-purple-400' + }, ] return ( diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx new file mode 100644 index 0000000..b150fb4 --- /dev/null +++ b/src/pages/AdminRemnawave.tsx @@ -0,0 +1,1090 @@ +import { useState, useMemo } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { + adminRemnawaveApi, + NodeInfo, + SquadWithLocalInfo, + SystemStatsResponse, + AutoSyncStatus, +} from '../api/adminRemnawave' + +// ============ Icons ============ + +const ServerIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +const ChartIcon = ({ className = "w-5 h-5" }: { className?: string }) => ( + + + +) + +const GlobeIcon = ({ className = "w-5 h-5" }: { className?: string }) => ( + + + +) + +const UsersIcon = ({ className = "w-5 h-5" }: { className?: string }) => ( + + + +) + +const SyncIcon = ({ className = "w-5 h-5" }: { className?: string }) => ( + + + +) + +const RefreshIcon = ({ className = "w-4 h-4", spinning = false }: { className?: string; spinning?: boolean }) => ( + + + +) + +const PlayIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( + + + +) + +const StopIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( + + + +) + +const ArrowPathIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( + + + +) + +const ChevronLeftIcon = () => ( + + + +) + +// ============ Helpers ============ + +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 formatUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + + if (days > 0) return `${days}d ${hours}h` + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` +} + +const getCountryFlag = (code: string | null | undefined): string => { + if (!code) return '🌍' + const codeMap: Record = { + 'RU': '🇷🇺', 'US': '🇺🇸', 'DE': '🇩🇪', 'NL': '🇳🇱', 'GB': '🇬🇧', 'UK': '🇬🇧', + 'FR': '🇫🇷', 'FI': '🇫🇮', 'SE': '🇸🇪', 'NO': '🇳🇴', 'PL': '🇵🇱', 'TR': '🇹🇷', + 'JP': '🇯🇵', 'SG': '🇸🇬', 'HK': '🇭🇰', 'KR': '🇰🇷', 'AU': '🇦🇺', 'CA': '🇨🇦', + 'CH': '🇨🇭', 'AT': '🇦🇹', 'IT': '🇮🇹', 'ES': '🇪🇸', 'BR': '🇧🇷', 'IN': '🇮🇳', + 'AE': '🇦🇪', 'IL': '🇮🇱', 'KZ': '🇰🇿', 'UA': '🇺🇦', 'CZ': '🇨🇿', 'RO': '🇷🇴', + 'LV': '🇱🇻', 'LT': '🇱🇹', 'EE': '🇪🇪', 'BG': '🇧🇬', 'HU': '🇭🇺', 'MD': '🇲🇩', + } + return codeMap[code.toUpperCase()] || code +} + +// ============ Sub-Components ============ + +interface StatCardProps { + label: string + value: string | number + icon: React.ReactNode + color?: string + subValue?: string +} + +function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardProps) { + const colorClasses: Record = { + accent: 'bg-accent-500/20 text-accent-400', + green: 'bg-emerald-500/20 text-emerald-400', + blue: 'bg-blue-500/20 text-blue-400', + orange: 'bg-orange-500/20 text-orange-400', + red: 'bg-red-500/20 text-red-400', + purple: 'bg-purple-500/20 text-purple-400', + } + + return ( +
+
+
+ {icon} +
+
+

{label}

+

{value}

+ {subValue &&

{subValue}

} +
+
+
+ ) +} + +interface NodeCardProps { + node: NodeInfo + onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void + isLoading?: boolean +} + +function NodeCard({ node, onAction, isLoading }: NodeCardProps) { + const { t } = useTranslation() + + const statusColor = node.is_disabled + ? 'text-dark-500' + : node.is_connected && node.is_node_online + ? 'text-emerald-400' + : 'text-red-400' + + const statusText = node.is_disabled + ? t('admin.remnawave.nodes.disabled', 'Disabled') + : node.is_connected && node.is_node_online + ? t('admin.remnawave.nodes.online', 'Online') + : t('admin.remnawave.nodes.offline', 'Offline') + + return ( +
+
+
+
+ {getCountryFlag(node.country_code)} +

{node.name}

+ + {statusText} + +
+

{node.address}

+ +
+ + + {node.users_online ?? 0} online + + {node.traffic_used_bytes !== undefined && ( + {formatBytes(node.traffic_used_bytes)} used + )} + {node.xray_uptime && ( + Uptime: {node.xray_uptime} + )} +
+
+ +
+ + +
+
+
+ ) +} + +interface SquadCardProps { + squad: SquadWithLocalInfo + onSelect: (squad: SquadWithLocalInfo) => void +} + +function SquadCard({ squad, onSelect }: SquadCardProps) { + const { t } = useTranslation() + + return ( +
onSelect(squad)} + className="p-4 bg-dark-800/50 rounded-xl border border-dark-700 hover:border-dark-600 transition-colors cursor-pointer" + > +
+
+
+ {getCountryFlag(squad.country_code)} +

+ {squad.display_name || squad.name} +

+ {squad.is_synced ? ( + + {t('admin.remnawave.squads.synced', 'Synced')} + + ) : ( + + {t('admin.remnawave.squads.notSynced', 'Not synced')} + + )} +
+

{squad.name}

+ +
+ + + {squad.members_count} members + + {squad.current_users !== undefined && ( + {squad.current_users} / {squad.max_users ?? '∞'} + )} + {squad.inbounds_count} inbounds + {squad.is_available !== undefined && ( + + {squad.is_available ? '✓ Available' : '✗ Unavailable'} + + )} +
+
+ + + + +
+
+ ) +} + +interface SyncCardProps { + title: string + description: string + onAction: () => void + isLoading?: boolean + lastResult?: { success: boolean; message?: string } | null +} + +function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncCardProps) { + return ( +
+
+
+

{title}

+

{description}

+ {lastResult && ( +

+ {lastResult.message} +

+ )} +
+ +
+
+ ) +} + +// ============ Tab Components ============ + +interface OverviewTabProps { + stats: SystemStatsResponse | undefined + isLoading: boolean + onRefresh: () => void +} + +function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { + const { t } = useTranslation() + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!stats) { + return ( +
+

{t('admin.remnawave.noData', 'Failed to load data')}

+ +
+ ) + } + + const memoryUsedPercent = stats.server_info.memory_total > 0 + ? Math.round((stats.server_info.memory_used / stats.server_info.memory_total) * 100) + : 0 + + return ( +
+ {/* System Stats */} +
+

+ + {t('admin.remnawave.overview.system', 'System')} +

+
+ } + color="green" + /> + } + color="blue" + /> + } + color="purple" + /> + } + color="orange" + /> +
+
+ + {/* Bandwidth */} +
+

+ {t('admin.remnawave.overview.bandwidth', 'Realtime Bandwidth')} +

+
+ ↓} + color="green" + /> + ↑} + color="blue" + /> + ⇅} + color="purple" + /> +
+
+ + {/* Server Info */} +
+

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

+
+ ⚡} + color="accent" + /> + 💾} + color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'} + /> + ⏱️} + color="blue" + /> +
+
+ + {/* Traffic Periods */} +
+

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

+
+ 📊} + color="accent" + /> + 📊} + color="blue" + /> + 📊} + color="green" + /> + 📊} + color="purple" + /> + 📊} + color="orange" + /> +
+
+ + {/* Users by Status */} +
+

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

+
+ {Object.entries(stats.users_by_status).map(([status, count]) => ( + } + color={status === 'ACTIVE' ? 'green' : status === 'DISABLED' ? 'red' : 'accent'} + /> + ))} +
+
+
+ ) +} + +interface NodesTabProps { + nodes: NodeInfo[] + isLoading: boolean + onRefresh: () => void + onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void + onRestartAll: () => void + isActionLoading: boolean +} + +function NodesTab({ nodes, isLoading, onRefresh, onAction, onRestartAll, isActionLoading }: NodesTabProps) { + const { t } = useTranslation() + + const stats = useMemo(() => { + const total = nodes.length + const online = nodes.filter(n => n.is_connected && n.is_node_online && !n.is_disabled).length + const offline = nodes.filter(n => (!n.is_connected || !n.is_node_online) && !n.is_disabled).length + const disabled = nodes.filter(n => n.is_disabled).length + const totalUsers = nodes.reduce((acc, n) => acc + (n.users_online ?? 0), 0) + return { total, online, offline, disabled, totalUsers } + }, [nodes]) + + if (isLoading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Stats */} +
+ } color="accent" /> + } color="green" /> + } color="red" /> + } color="accent" /> + } color="blue" /> +
+ + {/* Actions */} +
+ + +
+ + {/* Nodes List */} +
+ {nodes.length === 0 ? ( +

{t('admin.remnawave.nodes.noNodes', 'No nodes found')}

+ ) : ( + nodes.map(node => ( + + )) + )} +
+
+ ) +} + +interface SquadsTabProps { + squads: SquadWithLocalInfo[] + isLoading: boolean + onRefresh: () => void + onSelect: (squad: SquadWithLocalInfo) => void + onSync: () => void + isSyncing: boolean +} + +function SquadsTab({ squads, isLoading, onRefresh, onSelect, onSync, isSyncing }: SquadsTabProps) { + const { t } = useTranslation() + + const stats = useMemo(() => { + const total = squads.length + const synced = squads.filter(s => s.is_synced).length + const available = squads.filter(s => s.is_available).length + const totalMembers = squads.reduce((acc, s) => acc + s.members_count, 0) + return { total, synced, available, totalMembers } + }, [squads]) + + if (isLoading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Stats */} +
+ } color="accent" /> + } color="green" /> + } color="blue" /> + } color="purple" /> +
+ + {/* Actions */} +
+ + +
+ + {/* Squads List */} +
+ {squads.length === 0 ? ( +

{t('admin.remnawave.squads.noSquads', 'No squads found')}

+ ) : ( + squads.map(squad => ( + + )) + )} +
+
+ ) +} + +interface SyncTabProps { + autoSyncStatus: AutoSyncStatus | undefined + isLoading: boolean + onRunAutoSync: () => void + onSyncFromPanel: () => void + onSyncToPanel: () => void + onValidate: () => void + onCleanup: () => void + onSyncStatuses: () => void + syncResults: Record + loadingStates: Record +} + +function SyncTab({ + autoSyncStatus, + isLoading, + onRunAutoSync, + onSyncFromPanel, + onSyncToPanel, + onValidate, + onCleanup, + onSyncStatuses, + syncResults, + loadingStates, +}: SyncTabProps) { + const { t } = useTranslation() + + if (isLoading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Auto Sync Status */} + {autoSyncStatus && ( +
+
+

+ + {t('admin.remnawave.sync.autoSync', 'Auto Sync')} +

+ + {autoSyncStatus.enabled ? 'Enabled' : 'Disabled'} + +
+ +
+
+

Schedule

+

{autoSyncStatus.times.length > 0 ? autoSyncStatus.times.join(', ') : '—'}

+
+
+

Next Run

+

{autoSyncStatus.next_run ? new Date(autoSyncStatus.next_run).toLocaleString() : '—'}

+
+
+

Last Run

+

{autoSyncStatus.last_run_finished_at ? new Date(autoSyncStatus.last_run_finished_at).toLocaleString() : '—'}

+
+
+

Status

+

+ {autoSyncStatus.is_running ? 'Running...' : autoSyncStatus.last_run_success ? 'Success' : autoSyncStatus.last_run_error || '—'} +

+
+
+ + +
+ )} + + {/* Manual Sync Options */} +
+

+ {t('admin.remnawave.sync.manual', 'Manual Sync')} +

+
+ + +
+
+ + {/* Subscriptions */} +
+

+ {t('admin.remnawave.sync.subscriptions', 'Subscriptions')} +

+
+ + + +
+
+
+ ) +} + +// ============ Main Component ============ + +type TabType = 'overview' | 'nodes' | 'squads' | 'sync' + +export default function AdminRemnawave() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + // State + const [activeTab, setActiveTab] = useState('overview') + const [selectedSquad, setSelectedSquad] = useState(null) + const [syncResults, setSyncResults] = useState>({}) + const [loadingStates, setLoadingStates] = useState>({}) + + // Queries + const { data: status } = useQuery({ + queryKey: ['admin-remnawave-status'], + queryFn: adminRemnawaveApi.getStatus, + }) + + const { data: systemStats, isLoading: isLoadingStats, refetch: refetchStats } = useQuery({ + queryKey: ['admin-remnawave-system'], + queryFn: adminRemnawaveApi.getSystemStats, + enabled: activeTab === 'overview', + refetchInterval: 30000, + }) + + const { data: nodesData, isLoading: isLoadingNodes, refetch: refetchNodes } = useQuery({ + queryKey: ['admin-remnawave-nodes'], + queryFn: adminRemnawaveApi.getNodes, + enabled: activeTab === 'nodes', + refetchInterval: 15000, + }) + + const { data: squadsData, isLoading: isLoadingSquads, refetch: refetchSquads } = useQuery({ + queryKey: ['admin-remnawave-squads'], + queryFn: adminRemnawaveApi.getSquads, + enabled: activeTab === 'squads', + }) + + const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({ + queryKey: ['admin-remnawave-autosync'], + queryFn: adminRemnawaveApi.getAutoSyncStatus, + enabled: activeTab === 'sync', + refetchInterval: 10000, + }) + + // Mutations + const nodeActionMutation = useMutation({ + mutationFn: ({ uuid, action }: { uuid: string; action: 'enable' | 'disable' | 'restart' }) => + adminRemnawaveApi.nodeAction(uuid, action), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-remnawave-nodes'] }) + }, + }) + + const restartAllMutation = useMutation({ + mutationFn: adminRemnawaveApi.restartAllNodes, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-remnawave-nodes'] }) + }, + }) + + const syncServersMutation = useMutation({ + mutationFn: adminRemnawaveApi.syncServers, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-remnawave-squads'] }) + }, + }) + + // Handlers + const handleNodeAction = (uuid: string, action: 'enable' | 'disable' | 'restart') => { + nodeActionMutation.mutate({ uuid, action }) + } + + const handleRestartAll = () => { + if (confirm(t('admin.remnawave.nodes.confirmRestartAll', 'Are you sure you want to restart all nodes?'))) { + restartAllMutation.mutate() + } + } + + const handleSyncServers = () => { + syncServersMutation.mutate() + } + + const handleSyncAction = async (key: string, action: () => Promise<{ success?: boolean; started?: boolean; message?: string }>) => { + setLoadingStates(prev => ({ ...prev, [key]: true })) + try { + const result = await action() + setSyncResults(prev => ({ ...prev, [key]: { success: result.success ?? result.started ?? false, message: result.message } })) + } catch (error) { + setSyncResults(prev => ({ ...prev, [key]: { success: false, message: 'Failed' } })) + } finally { + setLoadingStates(prev => ({ ...prev, [key]: false })) + } + } + + const tabs = [ + { id: 'overview' as const, label: t('admin.remnawave.tabs.overview', 'Overview'), icon: }, + { id: 'nodes' as const, label: t('admin.remnawave.tabs.nodes', 'Nodes'), icon: }, + { id: 'squads' as const, label: t('admin.remnawave.tabs.squads', 'Squads'), icon: }, + { id: 'sync' as const, label: t('admin.remnawave.tabs.sync', 'Sync'), icon: }, + ] + + const isConfigured = status?.is_configured + + return ( +
+ {/* Header */} +
+
+ + + +
+ +
+
+

+ {t('admin.remnawave.title', 'RemnaWave')} +

+

+ {t('admin.remnawave.subtitle', 'Panel management and statistics')} +

+
+
+ + {/* Connection Status Badge */} +
+ + {isConfigured ? t('admin.remnawave.connected', 'Connected') : t('admin.remnawave.disconnected', 'Not configured')} +
+
+ + {/* Configuration Error */} + {status?.configuration_error && ( +
+

{status.configuration_error}

+
+ )} + + {/* Tabs */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Tab Content */} + {activeTab === 'overview' && ( + refetchStats()} + /> + )} + + {activeTab === 'nodes' && ( + refetchNodes()} + onAction={handleNodeAction} + onRestartAll={handleRestartAll} + isActionLoading={nodeActionMutation.isPending || restartAllMutation.isPending} + /> + )} + + {activeTab === 'squads' && ( + refetchSquads()} + onSelect={setSelectedSquad} + onSync={handleSyncServers} + isSyncing={syncServersMutation.isPending} + /> + )} + + {activeTab === 'sync' && ( + handleSyncAction('autoSync', adminRemnawaveApi.runAutoSync)} + onSyncFromPanel={() => handleSyncAction('fromPanel', () => adminRemnawaveApi.syncFromPanel('all'))} + onSyncToPanel={() => handleSyncAction('toPanel', adminRemnawaveApi.syncToPanel)} + onValidate={() => handleSyncAction('validate', adminRemnawaveApi.validateSubscriptions)} + onCleanup={() => handleSyncAction('cleanup', adminRemnawaveApi.cleanupSubscriptions)} + onSyncStatuses={() => handleSyncAction('statuses', adminRemnawaveApi.syncSubscriptionStatuses)} + syncResults={syncResults} + loadingStates={loadingStates} + /> + )} + + {/* Squad Detail Modal */} + {selectedSquad && ( +
setSelectedSquad(null)}> +
e.stopPropagation()}> + {/* Header */} +
+
+ {getCountryFlag(selectedSquad.country_code)} +

+ {selectedSquad.display_name || selectedSquad.name} +

+
+ +
+ + {/* Content */} +
+
+
+

UUID

+

{selectedSquad.uuid}

+
+
+

Original Name

+

{selectedSquad.name}

+
+
+

Members

+

{selectedSquad.members_count}

+
+
+

Inbounds

+

{selectedSquad.inbounds_count}

+
+ {selectedSquad.is_synced && ( + <> +
+

Price

+

{((selectedSquad.price_kopeks ?? 0) / 100).toFixed(2)} ₽

+
+
+

Users

+

{selectedSquad.current_users ?? 0} / {selectedSquad.max_users ?? '∞'}

+
+
+

Available

+

+ {selectedSquad.is_available ? 'Yes' : 'No'} +

+
+
+

Trial Eligible

+

+ {selectedSquad.is_trial_eligible ? 'Yes' : 'No'} +

+
+ + )} +
+ + {/* Inbounds */} + {selectedSquad.inbounds.length > 0 && ( +
+

Inbounds

+
+ {selectedSquad.inbounds.map((inbound: Record, idx) => ( +
+ {String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)} +
+ ))} +
+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ )} +
+ ) +}