Files
router-lists-ui/frontend/src/EasySwitchManager.jsx
T

1265 lines
63 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="container-xl">
<PageHeader
title="Easy Switch"
icon={<IconWorld size={24} />}
meta="Быстрое переключение шлюзов"
/>
<div className="card">
<div className="card-body text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
<div className="text-muted mt-3">Загрузка данных...</div>
</div>
</div>
</div>
);
}
return (
<div className="container-xl">
<PageHeader
title="Easy Switch"
icon={<IconWorld size={24} />}
meta={`Быстрое переключение шлюзов • ${servers.length} ${servers.length === 1 ? 'сервер' : 'серверов'}${communitiesDirectory.length} communities`}
actions={
<div className="btn-list">
{hasChanges && (
<button
className="btn btn-success"
onClick={handleApplyChanges}
>
<IconDeviceFloppy size={18} className="me-2" />
Применить изменения
</button>
)}
<button
className="btn btn-primary"
onClick={handleRefresh}
disabled={loading}
>
<IconRefresh size={18} className="me-2" />
Обновить
</button>
</div>
}
/>
{/* Вкладки */}
<div className="mb-3">
<ul className="nav nav-tabs" role="tablist">
<li className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === 'proxies' ? 'active' : ''}`}
onClick={() => setActiveTab('proxies')}
type="button"
>
Прокси ({communitiesDirectory.length})
</button>
</li>
<li className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === 'providers' ? 'active' : ''}`}
onClick={() => setActiveTab('providers')}
type="button"
>
Провайдер прокси (1)
</button>
</li>
</ul>
</div>
{/* Уведомления */}
{error && (
<div className="alert alert-danger alert-dismissible" role="alert">
<div className="d-flex">
<IconAlertCircle className="me-2" />
<div>{error}</div>
</div>
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<IconCheck className="me-2" />
<div>{success}</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Панель фильтров */}
<div className="mb-3">
<div className="row g-2 align-items-center">
{/* Переключатель "Только настроенные" */}
<div className="col-auto">
<label className="form-check form-switch mb-0">
<input
className="form-check-input"
type="checkbox"
checked={showOnlyConfigured}
onChange={(e) => setShowOnlyConfigured(e.target.checked)}
/>
<span className="form-check-label">Только настроенные фильтры</span>
</label>
</div>
{/* Переключатель "Группировка по тегам" */}
<div className="col-auto">
<label className="form-check form-switch mb-0">
<input
className="form-check-input"
type="checkbox"
checked={groupByTags}
onChange={(e) => setGroupByTags(e.target.checked)}
/>
<span className="form-check-label">Группировать по тегам</span>
</label>
</div>
{/* Поиск */}
<div className="col">
<div className="input-icon">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск | Ключевое слово..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<span
className="input-icon-addon"
style={{ right: 0, cursor: 'pointer' }}
onClick={() => setSearchQuery('')}
>
<IconX size={18} />
</span>
)}
</div>
</div>
{/* Инструменты */}
<div className="col-auto">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => {
if (expandedServers.size === filteredServers.length) {
setExpandedServers(new Set());
} else {
setExpandedServers(new Set(filteredServers.map(s => s.id)));
}
}}
title={expandedServers.size === filteredServers.length ? "Свернуть все" : "Развернуть все"}
>
{expandedServers.size === filteredServers.length ? (
<>
<IconChevronUp size={18} className="me-1" />
Свернуть все
</>
) : (
<>
<IconChevronDown size={18} className="me-1" />
Развернуть все
</>
)}
</button>
</div>
<div className="col-auto">
<button className="btn btn-icon" title="Настройки">
<IconSettings size={18} />
</button>
</div>
<div className="col-auto">
<button className="btn btn-icon" title="Быстрые действия">
<IconFilter size={18} />
</button>
</div>
</div>
</div>
{/* Список серверов с communities и шлюзами */}
{filteredServers.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
<IconFilter size={48} className="text-muted mb-3" />
<h3 className="text-muted">Ничего не найдено</h3>
<p className="text-muted">
{searchQuery ? 'Попробуйте изменить критерии поиска' : 'Нет доступных серверов'}
</p>
</div>
</div>
) : (
<div className="space-y-3">
{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 (
<div key={server.id} className="card mb-3">
{/* Заголовок сервера (кликабельный) */}
<div
className="card-header py-2"
style={{
backgroundColor: 'rgba(32, 107, 196, 0.05)',
cursor: 'pointer',
userSelect: 'none'
}}
onClick={() => toggleServerExpanded(server.id)}
>
<div className="d-flex align-items-center">
{/* Иконка сворачивания */}
{isExpanded ? (
<IconChevronUp size={20} className="me-2 text-muted" />
) : (
<IconChevronDown size={20} className="me-2 text-muted" />
)}
{flag && <span className="me-2" style={{ fontSize: '1.5rem' }}>{flag}</span>}
<IconServer size={20} className="me-2 text-primary" />
<h3 className="card-title mb-0 me-2">{server.name}</h3>
{server.description && (
<span className="text-muted small"> {server.description}</span>
)}
<span className="badge bg-blue-lt text-blue ms-auto">
{filteredCommunitiesForServer.length} communities {server.gateways.length} gateways
</span>
</div>
</div>
{/* Контент (сворачиваемый) */}
{isExpanded && (
<div className="card-body p-3">
{/* Условный рендеринг: группировка по тегам или обычный вид */}
{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 (
<div key={tag} className="mb-4">
{/* Заголовок группы тегов */}
<div className="mb-2 pb-2 border-bottom">
<span className="badge bg-purple-lt text-purple me-2" style={{ fontSize: '0.85rem', padding: '6px 10px' }}>
{tag}
</span>
<span className="text-muted small">
{tagCommunities.length} {tagCommunities.length === 1 ? 'community' : 'communities'}
</span>
</div>
{/* Сетка Communities для этого тега */}
<div className="row g-3">
{tagCommunities.map((comm) => {
const activeKey = `${server.id}:${comm.value}`;
const activeGw = activeGateways[activeKey];
const hasFilterForComm = server.filtersMap.has(comm.value);
return (
<div key={comm.value} className="col-12 col-md-6 col-lg-4">
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
настроен
</span>
)}
<span className="text-muted small ms-2">
({server.gateways.length})
</span>
</div>
{/* Сетка карточек шлюзов для этого community */}
<div className="row g-2">
{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 (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
onClick={() => 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)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<IconBolt size={10} className="me-1" />
Fast
</span>
)}
</div>
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
>
{gw.comment || gw.name}
</div>
</div>
</div>
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate ${
isActive ? 'text-white-75' : 'text-muted'
}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
{ping != null ? ping : '—'}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
{/* Communities без тегов */}
{noTagCommunities.length > 0 && (
<div className="mb-4">
{/* Заголовок группы без тегов */}
<div className="mb-2 pb-2 border-bottom">
<span className="badge bg-gray-lt text-gray me-2" style={{ fontSize: '0.85rem', padding: '6px 10px' }}>
Без тегов
</span>
<span className="text-muted small">
{noTagCommunities.length} {noTagCommunities.length === 1 ? 'community' : 'communities'}
</span>
</div>
{/* Сетка Communities без тегов */}
<div className="row g-3">
{noTagCommunities.map((comm) => {
const activeKey = `${server.id}:${comm.value}`;
const activeGw = activeGateways[activeKey];
const hasFilterForComm = server.filtersMap.has(comm.value);
return (
<div key={comm.value} className="col-12 col-md-6 col-lg-4">
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
настроен
</span>
)}
<span className="text-muted small ms-2">
({server.gateways.length})
</span>
</div>
{/* Сетка карточек шлюзов для этого community */}
<div className="row g-2">
{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 (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
onClick={() => 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)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<IconBolt size={10} className="me-1" />
Fast
</span>
)}
</div>
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
>
{gw.comment || gw.name}
</div>
</div>
</div>
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate ${
isActive ? 'text-white-75' : 'text-muted'
}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
{ping != null ? ping : '—'}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</>
);
})()
) : (
/* Обычный вид без группировки */
<div className="row g-3">
{filteredCommunitiesForServer.map((comm) => {
const activeKey = `${server.id}:${comm.value}`;
const activeGw = activeGateways[activeKey];
const hasFilterForComm = server.filtersMap.has(comm.value);
return (
<div key={comm.value} className="col-12 col-md-6 col-lg-4">
<div className="mb-2">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
{comm.value}
</span>
<span className="fw-medium">{comm.name || comm.description || ''}</span>
{hasFilterForComm && (
<span className="badge bg-green-lt text-green ms-2" style={{ fontSize: '0.7rem' }}>
настроен
</span>
)}
<span className="text-muted small ms-2">
({server.gateways.length})
</span>
</div>
{/* Сетка карточек шлюзов для этого community */}
<div className="row g-2">
{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 (
<div key={gw.name} className="col-12 col-sm-4">
<div
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
borderRadius: '6px',
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none',
position: 'relative'
}}
onClick={() => 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)';
}
}}
>
<div className="card-body p-2">
{/* Галочка (абсолютное позиционирование) */}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '20px',
height: '20px',
position: 'absolute',
top: '8px',
right: '8px'
}}
>
<IconCheck size={12} />
</div>
)}
{/* Метка Fastest */}
<div className="mb-1" style={{ minHeight: '18px' }}>
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
<IconBolt size={10} className="me-1" />
Fast
</span>
)}
</div>
{/* Флаг и название (комментарий) шлюза */}
<div className="d-flex align-items-center gap-2 mb-1">
<span
className="d-flex align-items-center justify-content-center flex-shrink-0 rounded"
style={{
width: '28px',
height: '28px',
fontSize: '1.25rem',
lineHeight: 1,
backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.06)',
}}
title={gw.country || country || ''}
>
{countryToFlag(gw.country || country || '') || '🌐'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className={`fw-bold ${isActive ? 'text-white' : ''}`}
style={{ fontSize: '0.85rem', lineHeight: '1.2', wordBreak: 'break-word' }}
>
{gw.comment || gw.name}
</div>
</div>
</div>
{/* IP шлюза ниже */}
{gw.ip && (
<div
className={`small text-truncate mb-1 ${isActive ? 'text-white-75' : 'text-muted'}`}
style={{ fontSize: '0.75rem' }}
>
{gw.ip}
</div>
)}
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
{ping != null ? ping : '—'}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
export default EasySwitchManager;