Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
1265 lines
63 KiB
React
1265 lines
63 KiB
React
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;
|
||
|