import { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import api from './lib/api.js'; import { usePing } from './contexts/PingContext.jsx'; import { IconSearch, IconRefresh, IconCheck, IconWorld, IconServer, IconFilter, IconAlertCircle, IconX, IconSettings, IconDeviceFloppy, IconBolt, IconChevronDown, IconChevronUp } from '@tabler/icons-react'; import PageHeader from './components/PageHeader.jsx'; // Функция для получения флага по коду страны const countryToFlag = (code) => { if (!code || typeof code !== 'string') return ''; const cc = code.trim().toUpperCase(); if (cc.length !== 2) return ''; const codePoints = [...cc].map((c) => 0x1f1e6 - 65 + c.charCodeAt(0)); return String.fromCodePoint(...codePoints); }; function EasySwitchManager() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); // Серверы и фильтры const [servers, setServers] = useState([]); const [inventoryServers, setInventoryServers] = useState([]); const [communitiesDirectory, setCommunitiesDirectory] = useState([]); // Справочник communities // Gateways и интерфейсы из /network-config (единый источник правды для шлюзов) const [networkConfigGateways, setNetworkConfigGateways] = useState([]); // Поиск и фильтры const [searchQuery, setSearchQuery] = useState(''); const [selectedRule, setSelectedRule] = useState('all'); // all | by-community const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные // Активные шлюзы для каждого community (ключ: community, значение: gateway name) const [activeGateways, setActiveGateways] = useState({}); const [initialGateways, setInitialGateways] = useState({}); // Исходное состояние для отслеживания изменений const [hasChanges, setHasChanges] = useState(false); const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам // Единый источник пингов (кеш + API) — общий с страницей фильтров const { pingMap, requestPings } = usePing(); const location = useLocation(); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; const initData = async () => { try { // Загружаем communities const commRes = await api.get(`/communities`, { signal }); const communities = Array.isArray(commRes.data) ? commRes.data : []; setCommunitiesDirectory(communities); // Загружаем inventory servers const invRes = await api.get(`/servers`, { signal }); const inventory = Array.isArray(invRes.data) ? invRes.data : []; setInventoryServers(inventory); // Загружаем network-config (gateways и туннели) let ncGateways = []; try { const ncRes = await api.get(`/network-config`, { signal }); const ncData = ncRes.data || {}; ncGateways = Array.isArray(ncData.gateways) ? ncData.gateways : []; } catch (e) { if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return; // Если файла нет или ошибка - используем пустой массив (fallback на старую логику) console.warn('EasySwitch: не удалось загрузить /network-config, используется fallback:', e?.message || e); ncGateways = []; } setNetworkConfigGateways(ncGateways); // Загружаем данные серверов и фильтров await loadDataWithParams(communities, inventory, ncGateways, signal); } catch (e) { if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return; console.error('Error initializing data:', e); setError('Ошибка загрузки данных'); } }; initData(); return () => controller.abort(); }, []); // При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся) useEffect(() => { if (location.pathname !== '/easy-switch' || !servers.length || !inventoryServers.length) return; const pingTasks = []; servers.forEach((server) => { const inv = inventoryServers.find(srv => String(srv.dns || '').trim() === server.name || String(srv.hostName || '').trim() === server.name || String(srv.ip || '').trim() === server.name ); const routerId = inv?.id || inv?.dns || inv?.ip; if (!routerId) return; (server.gateways || []).forEach((gw) => { if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip }); }); }); requestPings(pingTasks); }, [location.pathname, servers, inventoryServers, requestPings]); const loadDataWithParams = async (communities, inventory, ncGatewaysParam, signal) => { setLoading(true); setError(''); const requestConfig = signal ? { signal } : {}; try { // Загружаем список серверов из server-configs const serversResponse = await api.get(`/server-configs`, requestConfig); const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : []; // Загружаем фильтры для каждого сервера (для определения активных gateways) const serversWithData = await Promise.all( serversList.map(async (server) => { try { // Загружаем filters для сервера const filtersResponse = await api.get(`/server-filters/${server.id}`, requestConfig); const filters = Array.isArray(filtersResponse.data) ? filtersResponse.data : []; // Получаем gateways сервера: // 1) из /network-config (основной источник, как на странице фильтров) // 2) fallback на inventory (/servers) // 3) fallback на server-configs const matchedInventoryServer = inventory.find((srv) => String(srv.dns || '').trim() === server.name || String(srv.hostName || '').trim() === server.name || String(srv.ip || '').trim() === server.name ); const serverDns = matchedInventoryServer?.dns || matchedInventoryServer?.hostName || server.name || ''; const serverId = matchedInventoryServer?.id || matchedInventoryServer?.ip || server.id || server.name || ''; const serverCountry = matchedInventoryServer?.country || server.country || ''; const serverProvider = matchedInventoryServer?.provider || server.provider || ''; let serverGateways = []; // ПРИОРИТЕТ 1: Gateways из /network-config (совпадают со страницей фильтров) const allNetworkGateways = Array.isArray(ncGatewaysParam) ? ncGatewaysParam : networkConfigGateways; if (allNetworkGateways.length > 0) { const networkGatewaysForServer = allNetworkGateways.filter((gw) => { if (!gw.serverId) return false; return ( gw.serverId === serverId || gw.serverId === matchedInventoryServer?.ip || gw.serverId === matchedInventoryServer?.dns || gw.serverId === server.name || gw.serverId === serverDns ); }); networkGatewaysForServer.forEach((gw) => { if (!gw.ip) return; // Пропускаем gateway без IP serverGateways.push({ // Используем IP как имя, чтобы совпадало с FilterManager и значением gateway в фильтрах name: gw.ip, ip: gw.ip, comment: gw.description || '', primary: false, country: gw.country || serverCountry, provider: gw.provider || serverProvider, }); }); } // ПРИОРИТЕТ 2: Fallback на старые gateways из inventoryServers (если нет в network-config) if (serverGateways.length === 0 && matchedInventoryServer && Array.isArray(matchedInventoryServer.gateways)) { serverGateways = matchedInventoryServer.gateways // Пропускаем шлюзы, у которых имя совпадает с названием сервера (fallback) .filter((gw) => gw.name && gw.name !== serverDns && gw.name !== server.name) .map((gw) => ({ name: gw.name, ip: gw.ip || '', comment: gw.comment || '', primary: gw.primary || false, country: gw.country || serverCountry, provider: gw.provider || serverProvider, })); } // ПРИОРИТЕТ 3: Fallback на gateways из server-configs if (serverGateways.length === 0 && Array.isArray(server.gateways)) { serverGateways = server.gateways .filter((gw) => gw.name && gw.name !== serverDns && gw.name !== server.name) .map((gw) => ({ name: gw.name, ip: gw.ip || '', comment: gw.comment || '', primary: gw.primary || false, country: gw.country || serverCountry, provider: gw.provider || serverProvider, })); } // Создаем map фильтров по community для быстрого поиска const filtersMap = new Map(); filters.forEach(filter => { filtersMap.set(filter.community, filter.gateway); }); return { ...server, gateways: serverGateways, filtersMap: filtersMap }; } catch (e) { console.error(`Error loading data for ${server.id}:`, e); return { ...server, gateways: [], filtersMap: new Map() }; } }) ); // Фильтруем серверы, у которых есть gateways const serversWithGateways = serversWithData.filter(s => s.gateways.length > 0); setServers(serversWithGateways); // Единая точка входа: запрашиваем пинги через контекст (кеш общий с фильтрами) const pingTasks = []; serversWithGateways.forEach((server) => { const inv = inventory.find(srv => String(srv.dns || '').trim() === server.name || String(srv.hostName || '').trim() === server.name || String(srv.ip || '').trim() === server.name ); const routerId = inv?.id || inv?.dns || inv?.ip; if (!routerId) return; (server.gateways || []).forEach((gw) => { if (gw.ip) pingTasks.push({ routerId, gatewayIp: gw.ip }); }); }); requestPings(pingTasks); // Отладочная информация try { console.log('[EasySwitch] serversWithGateways:', serversWithGateways); console.log('[EasySwitch] communitiesDirectory:', communities); } catch (_) {} // Автоматически разворачиваем первый сервер if (serversWithGateways.length > 0 && expandedServers.size === 0) { setExpandedServers(new Set([serversWithGateways[0].id])); } // Устанавливаем активные шлюзы ТОЛЬКО из filters (не добавляем дефолтные) const initialActive = {}; serversWithGateways.forEach(server => { communities.forEach(comm => { const key = `${server.id}:${comm.value}`; // Берем gateway ТОЛЬКО из filters const gatewayFromFilter = server.filtersMap.get(comm.value); if (gatewayFromFilter && server.gateways.find(g => g.name === gatewayFromFilter)) { initialActive[key] = gatewayFromFilter; } // Если нет в filters - НЕ устанавливаем дефолтное значение }); }); setActiveGateways(initialActive); setInitialGateways({ ...initialActive }); // Сохраняем исходное состояние try { console.log('[EasySwitch] initialActiveGateways:', initialActive); } catch (_) {} } catch (err) { if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; console.error('Error loading data:', err); setError('Ошибка загрузки данных. Проверьте подключение к серверу.'); } finally { setLoading(false); } }; const handleGatewaySelect = (serverId, community, gateway) => { const key = `${serverId}:${community}`; setActiveGateways(prev => { const newState = { ...prev, [key]: gateway }; // Проверяем, есть ли реальные изменения относительно исходного состояния const hasRealChanges = Object.keys(newState).some(k => { return newState[k] !== initialGateways[k]; }); setHasChanges(hasRealChanges); return newState; }); }; const toggleServerExpanded = (serverId) => { setExpandedServers(prev => { const newSet = new Set(prev); if (newSet.has(serverId)) { newSet.delete(serverId); } else { newSet.add(serverId); } return newSet; }); }; const handleApplyChanges = async () => { setLoading(true); setError(''); try { // Находим только РЕАЛЬНО измененные записи const changedEntries = Object.entries(activeGateways).filter(([key, gateway]) => { return initialGateways[key] !== gateway; }); if (changedEntries.length === 0) { setError('Нет изменений для сохранения'); setLoading(false); setTimeout(() => setError(''), 3000); return; } // Группируем ТОЛЬКО измененные записи по serverId const changesByServer = new Map(); changedEntries.forEach(([key, gateway]) => { // Разбиваем ключ по ПЕРВОМУ двоеточию, т.к. community содержит двоеточие (65001:120) const firstColonIndex = key.indexOf(':'); const serverId = key.substring(0, firstColonIndex); const community = key.substring(firstColonIndex + 1); if (!changesByServer.has(serverId)) { changesByServer.set(serverId, new Map()); } changesByServer.get(serverId).set(community, gateway); }); // Обновляем фильтры ТОЛЬКО для серверов с изменениями const updatePromises = Array.from(changesByServer.entries()).map(async ([serverId, communitiesMap]) => { try { // Получаем текущие фильтры сервера const response = await api.get(`/server-filters/${serverId}`); const currentFilters = Array.isArray(response.data) ? response.data : []; // Создаем Set существующих communities в фильтрах const existingCommunitiesSet = new Set(currentFilters.map(f => f.community)); // Обновляем существующие фильтры и добавляем новые const updatedFilters = []; const processedCommunities = new Set(); // Обновляем существующие currentFilters.forEach(filter => { const newGateway = communitiesMap.get(filter.community); if (newGateway) { // Обновляем gateway updatedFilters.push({ ...filter, gateway: newGateway }); } else { // Оставляем без изменений updatedFilters.push(filter); } // ВАЖНО: добавляем в processedCommunities в любом случае processedCommunities.add(filter.community); }); // Добавляем новые фильтры для communities, которых не было communitiesMap.forEach((gateway, community) => { if (!processedCommunities.has(community)) { // Находим описание из справочника const commInfo = communitiesDirectory.find(c => c.value === community); updatedFilters.push({ community: community, gateway: gateway, description: commInfo?.name || commInfo?.description || '' }); } }); // Сохраняем обновленные фильтры await api.post(`/server-filters/${serverId}`, { filters: updatedFilters }); return { serverId, success: true }; } catch (error) { console.error(`Error updating filters for ${serverId}:`, error); return { serverId, success: false, error }; } }); const results = await Promise.all(updatePromises); const successCount = results.filter(r => r.success).length; const errorCount = results.filter(r => !r.success).length; if (errorCount === 0) { setHasChanges(false); setSuccess(`✅ Изменения успешно применены для ${successCount} ${successCount === 1 ? 'сервера' : 'серверов'}`); setTimeout(() => setSuccess(''), 3000); // Перезагружаем данные чтобы обновить бейджи "настроен" и сбросить initialGateways await handleRefresh(); } else { setError(`Не удалось обновить ${errorCount} ${errorCount === 1 ? 'сервер' : 'серверов'}. Успешно: ${successCount}.`); setTimeout(() => setError(''), 5000); } } catch (err) { console.error('Error applying changes:', err); setError('Ошибка при применении изменений. Проверьте подключение к серверу.'); } finally { setLoading(false); } }; const handleRefresh = async () => { try { // Загружаем communities const commRes = await api.get(`/communities`); const communities = Array.isArray(commRes.data) ? commRes.data : []; setCommunitiesDirectory(communities); // Загружаем inventory servers const invRes = await api.get(`/servers`); const inventory = Array.isArray(invRes.data) ? invRes.data : []; setInventoryServers(inventory); // Загружаем network-config (gateways и туннели) let ncGateways = []; try { const ncRes = await api.get(`/network-config`); const ncData = ncRes.data || {}; ncGateways = Array.isArray(ncData.gateways) ? ncData.gateways : []; } catch (e) { console.warn('EasySwitch: не удалось обновить /network-config, используется fallback:', e?.message || e); ncGateways = []; } setNetworkConfigGateways(ncGateways); // Загружаем данные серверов и фильтров с учетом network-config gateways await loadDataWithParams(communities, inventory, ncGateways); } catch (e) { console.error('Error refreshing data:', e); setError('Ошибка обновления данных'); } }; // Получаем метаданные сервера из inventory const getServerMetadata = (serverName) => { return inventoryServers.find(s => s.dns === serverName || s.hostName === serverName || s.ip === serverName ); }; // Фильтрация серверов по поиску const filteredServers = servers.filter(server => { if (!searchQuery.trim()) return true; const search = searchQuery.toLowerCase(); const serverName = (server.name || '').toLowerCase(); const serverDesc = (server.description || '').toLowerCase(); // Поиск по имени/описанию сервера if (serverName.includes(search) || serverDesc.includes(search)) return true; // Поиск по communities и gateways return communitiesDirectory.some(comm => { const commValue = (comm.value || '').toLowerCase(); const commName = (comm.name || '').toLowerCase(); const commDesc = (comm.description || '').toLowerCase(); return commValue.includes(search) || commName.includes(search) || commDesc.includes(search); }) || server.gateways.some(g => (g.name || '').toLowerCase().includes(search)); }); if (loading && servers.length === 0) { return (
} meta="Быстрое переключение шлюзов" />
Загрузка...
Загрузка данных...
); } return (
} meta={`Быстрое переключение шлюзов • ${servers.length} ${servers.length === 1 ? 'сервер' : 'серверов'} • ${communitiesDirectory.length} communities`} actions={
{hasChanges && ( )}
} /> {/* Вкладки */}
{/* Уведомления */} {error && (
{error}
)} {success && (
{success}
)} {/* Панель фильтров */}
{/* Переключатель "Только настроенные" */}
{/* Переключатель "Группировка по тегам" */}
{/* Поиск */}
setSearchQuery(e.target.value)} /> {searchQuery && ( setSearchQuery('')} > )}
{/* Инструменты */}
{/* Список серверов с communities и шлюзами */} {filteredServers.length === 0 ? (

Ничего не найдено

{searchQuery ? 'Попробуйте изменить критерии поиска' : 'Нет доступных серверов'}

) : (
{filteredServers.map((server) => { const serverMeta = getServerMetadata(server.name); const country = serverMeta?.country || ''; const flag = countryToFlag(country); // Фильтруем communities для этого сервера const filteredCommunitiesForServer = communitiesDirectory.filter((comm) => { if (showOnlyConfigured) { return server.filtersMap.has(comm.value); } return true; }); // Не показываем сервер, если нет отфильтрованных communities if (filteredCommunitiesForServer.length === 0) return null; const isExpanded = expandedServers.has(server.id); return (
{/* Заголовок сервера (кликабельный) */}
toggleServerExpanded(server.id)} >
{/* Иконка сворачивания */} {isExpanded ? ( ) : ( )} {flag && {flag}}

{server.name}

{server.description && ( — {server.description} )} {filteredCommunitiesForServer.length} communities • {server.gateways.length} gateways
{/* Контент (сворачиваемый) */} {isExpanded && (
{/* Условный рендеринг: группировка по тегам или обычный вид */} {groupByTags ? ( /* Группируем communities по тегам */ (() => { // Группируем по тегам const groupedByTags = new Map(); const noTagCommunities = []; filteredCommunitiesForServer.forEach(comm => { const tags = Array.isArray(comm.tags) ? comm.tags : (comm.tags ? String(comm.tags).split(',').map(t => t.trim()).filter(Boolean) : []); if (tags.length === 0) { noTagCommunities.push(comm); } else { tags.forEach(tag => { if (!groupedByTags.has(tag)) { groupedByTags.set(tag, []); } groupedByTags.get(tag).push(comm); }); } }); // Сортируем теги const sortedTags = Array.from(groupedByTags.keys()).sort(); return ( <> {/* Communities с тегами */} {sortedTags.map(tag => { const tagCommunities = groupedByTags.get(tag); return (
{/* Заголовок группы тегов */}
{tag} {tagCommunities.length} {tagCommunities.length === 1 ? 'community' : 'communities'}
{/* Сетка Communities для этого тега */}
{tagCommunities.map((comm) => { const activeKey = `${server.id}:${comm.value}`; const activeGw = activeGateways[activeKey]; const hasFilterForComm = server.filtersMap.has(comm.value); return (
{/* Заголовок community */}
{comm.value} {comm.name || comm.description || ''} {hasFilterForComm && ( настроен )} ({server.gateways.length})
{/* Сетка карточек шлюзов для этого community */}
{server.gateways.map((gw, idx) => { const isActive = activeGw === gw.name; const isFastest = idx === 0; const pingKey = (() => { const inv = getServerMetadata(server.name); const routerId = inv?.id || inv?.dns || inv?.ip; return routerId && gw.ip ? `${routerId}:${gw.ip}` : null; })(); const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null; return (
handleGatewaySelect(server.id, comm.value, gw.name)} onMouseEnter={(e) => { if (!isActive) { e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)'; e.currentTarget.style.transform = 'translateY(-1px)'; } }} onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'translateY(0)'; } }} >
{/* Галочка (абсолютное позиционирование) */} {isActive && (
)} {/* Метка Fastest */}
{isFastest && !isActive && ( Fast )}
{/* Флаг и название (комментарий) шлюза */}
{countryToFlag(gw.country || country || '') || '🌐'}
{gw.comment || gw.name}
{/* IP шлюза ниже */} {gw.ip && (
{gw.ip}
)} {/* Метрика (пинг) */}
{ping != null ? ping : '—'}
); })}
); })}
); })} {/* Communities без тегов */} {noTagCommunities.length > 0 && (
{/* Заголовок группы без тегов */}
Без тегов {noTagCommunities.length} {noTagCommunities.length === 1 ? 'community' : 'communities'}
{/* Сетка Communities без тегов */}
{noTagCommunities.map((comm) => { const activeKey = `${server.id}:${comm.value}`; const activeGw = activeGateways[activeKey]; const hasFilterForComm = server.filtersMap.has(comm.value); return (
{/* Заголовок community */}
{comm.value} {comm.name || comm.description || ''} {hasFilterForComm && ( настроен )} ({server.gateways.length})
{/* Сетка карточек шлюзов для этого community */}
{server.gateways.map((gw, idx) => { const isActive = activeGw === gw.name; const isFastest = idx === 0; const pingKey = (() => { const inv = getServerMetadata(server.name); const routerId = inv?.id || inv?.dns || inv?.ip; return routerId && gw.ip ? `${routerId}:${gw.ip}` : null; })(); const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null; return (
handleGatewaySelect(server.id, comm.value, gw.name)} onMouseEnter={(e) => { if (!isActive) { e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)'; e.currentTarget.style.transform = 'translateY(-1px)'; } }} onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'translateY(0)'; } }} >
{/* Галочка (абсолютное позиционирование) */} {isActive && (
)} {/* Метка Fastest */}
{isFastest && !isActive && ( Fast )}
{/* Флаг и название (комментарий) шлюза */}
{countryToFlag(gw.country || country || '') || '🌐'}
{gw.comment || gw.name}
{/* IP шлюза ниже */} {gw.ip && (
{gw.ip}
)} {/* Метрика (пинг) */}
{ping != null ? ping : '—'}
); })}
); })}
)} ); })() ) : ( /* Обычный вид без группировки */
{filteredCommunitiesForServer.map((comm) => { const activeKey = `${server.id}:${comm.value}`; const activeGw = activeGateways[activeKey]; const hasFilterForComm = server.filtersMap.has(comm.value); return (
{/* Заголовок community */}
{comm.value} {comm.name || comm.description || ''} {hasFilterForComm && ( настроен )} ({server.gateways.length})
{/* Сетка карточек шлюзов для этого community */}
{server.gateways.map((gw, idx) => { const isActive = activeGw === gw.name; const isFastest = idx === 0; const pingKey = (() => { const inv = getServerMetadata(server.name); const routerId = inv?.id || inv?.dns || inv?.ip; return routerId && gw.ip ? `${routerId}:${gw.ip}` : null; })(); const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null; return (
handleGatewaySelect(server.id, comm.value, gw.name)} onMouseEnter={(e) => { if (!isActive) { e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)'; e.currentTarget.style.transform = 'translateY(-1px)'; } }} onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'translateY(0)'; } }} >
{/* Галочка (абсолютное позиционирование) */} {isActive && (
)} {/* Метка Fastest */}
{isFastest && !isActive && ( Fast )}
{/* Флаг и название (комментарий) шлюза */}
{countryToFlag(gw.country || country || '') || '🌐'}
{gw.comment || gw.name}
{/* IP шлюза ниже */} {gw.ip && (
{gw.ip}
)} {/* Метрика (пинг) */}
{ping != null ? ping : '—'}
); })}
); })}
)}
)}
); })}
)}
); } export default EasySwitchManager;