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

756 lines
32 KiB
React

import { useState, useEffect } from 'react';
import api from './lib/api.js';
import {
IconSearch,
IconRefresh,
IconCheck,
IconWorld,
IconServer,
IconFilter,
IconAlertCircle,
IconX,
IconSettings,
IconDeviceFloppy,
IconBolt,
IconChevronDown,
IconChevronUp
} from '@tabler/icons-react';
// Функция для получения флага по коду страны
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
// Поиск и фильтры
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()); // Развернутые серверы
useEffect(() => {
const initData = 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);
// Загружаем данные серверов и фильтров
await loadDataWithParams(communities, inventory);
} catch (e) {
console.error('Error initializing data:', e);
setError('Ошибка загрузки данных');
}
};
initData();
}, []);
const loadDataWithParams = async (communities, inventory) => {
setLoading(true);
setError('');
try {
// Загружаем список серверов из server-configs
const serversResponse = await api.get(`/server-configs`);
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}`);
const filters = Array.isArray(filtersResponse.data) ? filtersResponse.data : [];
// Получаем gateways сервера из inventory или 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
);
let serverGateways = [];
// Берем gateways из inventory
if (matchedInventoryServer && Array.isArray(matchedInventoryServer.gateways)) {
serverGateways = matchedInventoryServer.gateways
.filter(gw => gw.name && gw.name !== server.name)
.map(gw => ({
name: gw.name,
ip: gw.ip || '',
comment: gw.comment || '',
primary: gw.primary || false
}));
}
// Если нет в inventory, берем из server-configs
if (serverGateways.length === 0 && Array.isArray(server.gateways)) {
serverGateways = server.gateways
.filter(gw => gw.name && gw.name !== server.name)
.map(gw => ({
name: gw.name,
ip: gw.ip || '',
comment: gw.comment || '',
primary: gw.primary || false
}));
}
// Создаем 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);
// Автоматически разворачиваем первый сервер
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 }); // Сохраняем исходное состояние
} catch (err) {
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);
// Загружаем данные серверов и фильтров
await loadDataWithParams(communities, inventory);
} 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">
<div className="page-header">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">
<IconWorld className="me-2" />
Easy Switch
</h2>
<div className="text-muted mt-1">Быстрое переключение шлюзов</div>
</div>
</div>
</div>
<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">
{/* Заголовок */}
<div className="page-header mb-3">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">
<IconWorld className="me-2" />
Easy Switch
</h2>
<div className="text-muted mt-1">
Быстрое переключение шлюзов {servers.length} {servers.length === 1 ? 'сервер' : 'серверов'} {communitiesDirectory.length} communities
</div>
</div>
<div className="col-auto">
<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>
</div>
</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">
<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">
{/* Сетка Communities для этого сервера */}
<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 ping = 20 + idx * 10 + Math.floor(Math.random() * 15);
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={`fw-bold mb-1 ${isActive ? 'text-white' : ''}`} style={{ fontSize: '0.85rem', lineHeight: '1.2' }}>
{gw.name}
</div>
{/* Описание шлюза */}
{gw.comment && (
<div className={`small mb-1 ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.8 : 1, lineHeight: '1.2' }}>
{gw.comment}
</div>
)}
{/* Протокол */}
<div className={`small mb-1 ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.85 : 1 }}>
{serverMeta?.provider || 'vless'}
</div>
{/* Метрика (пинг) */}
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
{ping}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
export default EasySwitchManager;