feat: Update Easy Switch functionality to improve server and gateway management, including enhanced grouping by servers, refined UI components, and updated data structure for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s

This commit is contained in:
2025-12-07 04:31:12 +07:00
parent 7091660b80
commit b13f412b79
2 changed files with 267 additions and 211 deletions
+80 -26
View File
@@ -2,20 +2,23 @@
## Описание ## Описание
Easy Switch - это новая страница для быстрого переключения шлюзов (gateways) на основе communities из фильтров. Интерфейс вдохновлен мобильными VPN приложениями и обеспечивает удобный способ управления маршрутизацией. Easy Switch - это новая страница для быстрого переключения шлюзов (gateways) внутри серверов на основе communities из фильтров. Интерфейс вдохновлен мобильными VPN приложениями и обеспечивает удобный способ управления маршрутизацией.
## Особенности ## Особенности
### 1. **Визуальный интерфейс** ### 1. **Визуальный интерфейс**
- Карточки серверов с флагами стран - Компактные карточки шлюзов (уменьшенный размер)
- Карточки с флагами стран
- Отображение пинга и протокола подключения - Отображение пинга и протокола подключения
- Метка "Fastest" для самого быстрого сервера в группе - Метка "Fast" для самого быстрого шлюза в группе
- Активный сервер выделен синим цветом - Активный шлюз выделен синим цветом
### 2. **Группировка по Communities** ### 2. **Группировка по Серверам**
- Все шлюзы сгруппированы по BGP communities - Сервера группируются в отдельные блоки
- Для каждого community можно выбрать активный шлюз - Каждый сервер показывает свои communities
- Отображается количество доступных шлюзов - Для каждого community на сервере можно выбрать активный шлюз **этого сервера**
- Переключение происходит между шлюзами **внутри одного сервера**
- Отображается количество communities на каждом сервере
### 3. **Поиск и фильтрация** ### 3. **Поиск и фильтрация**
- Поиск по community, описанию или имени шлюза - Поиск по community, описанию или имени шлюза
@@ -53,6 +56,42 @@ Easy Switch - это новая страница для быстрого пер
} }
``` ```
### Структура данных в приложении
После загрузки данные организованы следующим образом:
```javascript
servers = [
{
id: "server-001",
name: "SWE-HIPHOST",
description: "Шведский сервер",
communities: [
{
community: "65001:100",
description: "Стриминг сервисы",
gateways: [
{ name: "gateway1" },
{ name: "gateway2" },
{ name: "gateway3" }
]
},
// ... другие communities
]
},
// ... другие серверы
]
```
Активные шлюзы хранятся в формате:
```javascript
activeGateways = {
"server-001:65001:100": "gateway1",
"server-002:65001:100": "gateway4",
// ключ: "{serverId}:{community}", значение: "{gatewayName}"
}
```
## Интерфейс ## Интерфейс
### Заголовок ### Заголовок
@@ -69,20 +108,30 @@ Easy Switch - это новая страница для быстрого пер
- Поле поиска - поиск по ключевым словам - Поле поиска - поиск по ключевым словам
- Кнопки инструментов (настройки, быстрые действия) - Кнопки инструментов (настройки, быстрые действия)
### Карточки серверов ### Блоки серверов
Каждая карточка содержит: Каждый сервер отображается в отдельной карточке с:
- 🏴 **Флаг страны** (если доступен) - 🏴 **Флаг страны** сервера
- **Метка Fastest** (для самого быстрого сервера) - 🖥️ **Иконка сервера**
- **Название** шлюза или страны - **Название** сервера
- **Протокол** подключения (vless/xudp и т.д.) - **Описание** сервера
- **Количество communities** на этом сервере
### Карточки шлюзов
Каждая карточка шлюза (компактная) содержит:
-**Метка Fast** (для первого шлюза в списке)
- **Название** шлюза
- **Протокол** подключения (vless и т.д.)
- **Пинг** в миллисекундах - **Пинг** в миллисекундах
-**Галочка** для активного сервера -**Галочка** для активного шлюза
### Состояния карточки Карточки имеют меньший размер (col-6 col-sm-4 col-md-3 col-lg-2) для компактности.
### Состояния карточки шлюза
- **Неактивная** - белый фон, серая рамка - **Неактивная** - белый фон, серая рамка
- **Активная** - синий фон, белый текст, тень - **Активная** - синий фон, белый текст, тень
- **Hover** - подъем карточки, легкая тень - **Hover** - подъем карточки на 1px, легкая тень
## Использование ## Использование
@@ -92,16 +141,20 @@ Easy Switch - это новая страница для быстрого пер
URL: `/easy-switch` URL: `/easy-switch`
### Переключение шлюза ### Переключение шлюза
1. Найдите нужный community в списке 1. Найдите нужный **сервер** в списке
2. Нажмите на карточку желаемого шлюза 2. В этом сервере найдите нужный **community**
3. Карточка станет синей (активной) 3. Нажмите на карточку желаемого **шлюза этого сервера**
4. Нажмите "Применить изменения" для сохранения 4. Карточка станет синей (активной)
5. Нажмите "Применить изменения" для сохранения
**Важно:** Переключение происходит между шлюзами **внутри одного сервера**. Каждый сервер имеет свой набор шлюзов для каждого community.
### Поиск ### Поиск
Введите в поле поиска: Введите в поле поиска:
- Название сервера (например, `SWE-HIPHOST`)
- Номер community (например, `65001:100`) - Номер community (например, `65001:100`)
- Описание (например, `Стриминг`) - Описание (например, `Стриминг`)
- Название шлюза (например, `SWE-HIPHOST`) - Название шлюза (например, `gateway1`)
## Технические детали ## Технические детали
@@ -124,12 +177,13 @@ GET /api/servers // Метаданные серверов
```javascript ```javascript
- loading: boolean // Загрузка данных - loading: boolean // Загрузка данных
- error: string // Текст ошибки - error: string // Текст ошибки
- servers: [] // Список серверов - servers: [] // Список серверов с communities и gateways
- communities: [] // Группы communities - inventoryServers: [] // Метаданные серверов (флаги, провайдеры)
- activeGateways: {} // Активные шлюзы - activeGateways: {} // Активные шлюзы (ключ: "serverId:community")
- hasChanges: boolean // Наличие изменений - hasChanges: boolean // Наличие изменений
- searchQuery: string // Поисковый запрос - searchQuery: string // Поисковый запрос
- activeTab: string // Активная вкладка - selectedRule: string // Выбранное правило фильтрации
- activeTab: string // Активная вкладка (proxies/providers)
``` ```
## Roadmap ## Roadmap
+183 -181
View File
@@ -3,7 +3,6 @@ import api from './lib/api.js';
import { import {
IconSearch, IconSearch,
IconRefresh, IconRefresh,
IconChevronDown,
IconCheck, IconCheck,
IconWorld, IconWorld,
IconServer, IconServer,
@@ -15,8 +14,6 @@ import {
IconBolt IconBolt
} from '@tabler/icons-react'; } from '@tabler/icons-react';
const API_URL = '/api';
// Функция для получения флага по коду страны // Функция для получения флага по коду страны
const countryToFlag = (code) => { const countryToFlag = (code) => {
if (!code || typeof code !== 'string') return ''; if (!code || typeof code !== 'string') return '';
@@ -26,20 +23,6 @@ const countryToFlag = (code) => {
return String.fromCodePoint(...codePoints); return String.fromCodePoint(...codePoints);
}; };
// Парсинг community value для отображения
const parseCommunity = (value) => {
if (!value) return { asn: '', local: '', display: '' };
const parts = String(value).split(':');
if (parts.length === 2) {
return {
asn: parts[0],
local: parts[1],
display: value
};
}
return { asn: '', local: '', display: value };
};
function EasySwitchManager() { function EasySwitchManager() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -47,8 +30,6 @@ function EasySwitchManager() {
// Серверы и фильтры // Серверы и фильтры
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [inventoryServers, setInventoryServers] = useState([]); const [inventoryServers, setInventoryServers] = useState([]);
const [allFilters, setAllFilters] = useState([]); // Все фильтры со всех серверов
const [communities, setCommunities] = useState([]); // Список communities для отображения
// Поиск и фильтры // Поиск и фильтры
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -81,61 +62,63 @@ function EasySwitchManager() {
// Загружаем список серверов из server-configs // Загружаем список серверов из server-configs
const serversResponse = await api.get(`/server-configs`); const serversResponse = await api.get(`/server-configs`);
const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : []; const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : [];
setServers(serversList);
// Загружаем фильтры для каждого сервера // Загружаем фильтры для каждого сервера
const filtersPromises = serversList.map(async (server) => { const serversWithFilters = await Promise.all(
try { serversList.map(async (server) => {
const response = await api.get(`/server-filters/${server.id}`); try {
const filters = Array.isArray(response.data) ? response.data : []; const response = await api.get(`/server-filters/${server.id}`);
return filters.map(f => ({ const filters = Array.isArray(response.data) ? response.data : [];
...f,
serverId: server.id,
serverName: server.name
}));
} catch (e) {
console.error(`Error loading filters for ${server.id}:`, e);
return [];
}
});
const filtersArrays = await Promise.all(filtersPromises); // Группируем фильтры по community внутри сервера
const combinedFilters = filtersArrays.flat(); const communitiesMap = new Map();
setAllFilters(combinedFilters); filters.forEach(filter => {
const comm = filter.community;
if (!communitiesMap.has(comm)) {
communitiesMap.set(comm, {
community: comm,
description: filter.description || '',
gateways: []
});
}
// Группируем по community const communityData = communitiesMap.get(comm);
const communityMap = new Map(); // Добавляем gateway, если его еще нет
combinedFilters.forEach(filter => { if (!communityData.gateways.find(g => g.name === filter.gateway)) {
const comm = filter.community; communityData.gateways.push({
if (!communityMap.has(comm)) { name: filter.gateway,
communityMap.set(comm, { description: filter.description
community: comm, });
description: filter.description || '', }
gateways: [] });
});
}
const communityData = communityMap.get(comm); return {
// Добавляем gateway, если его еще нет ...server,
if (!communityData.gateways.find(g => g.gateway === filter.gateway && g.serverId === filter.serverId)) { communities: Array.from(communitiesMap.values())
communityData.gateways.push({ };
gateway: filter.gateway, } catch (e) {
serverId: filter.serverId, console.error(`Error loading filters for ${server.id}:`, e);
serverName: filter.serverName, return {
description: filter.description ...server,
}); communities: []
} };
}); }
})
);
const communitiesArray = Array.from(communityMap.values()); // Фильтруем серверы, у которых есть communities
setCommunities(communitiesArray); const serversWithCommunities = serversWithFilters.filter(s => s.communities.length > 0);
setServers(serversWithCommunities);
// Устанавливаем активные шлюзы (первый для каждого community) // Устанавливаем активные шлюзы (первый gateway для каждого community на каждом сервере)
const initialActive = {}; const initialActive = {};
communitiesArray.forEach(comm => { serversWithCommunities.forEach(server => {
if (comm.gateways.length > 0) { server.communities.forEach(comm => {
initialActive[comm.community] = comm.gateways[0].gateway; if (comm.gateways.length > 0) {
} const key = `${server.id}:${comm.community}`;
initialActive[key] = comm.gateways[0].name;
}
});
}); });
setActiveGateways(initialActive); setActiveGateways(initialActive);
@@ -147,10 +130,11 @@ function EasySwitchManager() {
} }
}; };
const handleGatewaySelect = (community, gateway) => { const handleGatewaySelect = (serverId, community, gateway) => {
const key = `${serverId}:${community}`;
setActiveGateways(prev => ({ setActiveGateways(prev => ({
...prev, ...prev,
[community]: gateway [key]: gateway
})); }));
setHasChanges(true); setHasChanges(true);
}; };
@@ -176,23 +160,29 @@ function EasySwitchManager() {
); );
}; };
// Фильтрация communities по поиску // Фильтрация серверов по поиску
const filteredCommunities = communities.filter(comm => { const filteredServers = servers.filter(server => {
if (!searchQuery.trim()) return true; if (!searchQuery.trim()) return true;
const search = searchQuery.toLowerCase(); const search = searchQuery.toLowerCase();
const commValue = (comm.community || '').toLowerCase(); const serverName = (server.name || '').toLowerCase();
const desc = (comm.description || '').toLowerCase(); const serverDesc = (server.description || '').toLowerCase();
return commValue.includes(search) || // Поиск по имени/описанию сервера
desc.includes(search) || if (serverName.includes(search) || serverDesc.includes(search)) return true;
comm.gateways.some(g =>
(g.gateway || '').toLowerCase().includes(search) || // Поиск по communities и gateways
(g.serverName || '').toLowerCase().includes(search) return server.communities.some(comm => {
); const commValue = (comm.community || '').toLowerCase();
const commDesc = (comm.description || '').toLowerCase();
return commValue.includes(search) ||
commDesc.includes(search) ||
comm.gateways.some(g => (g.name || '').toLowerCase().includes(search));
});
}); });
if (loading && communities.length === 0) { if (loading && servers.length === 0) {
return ( return (
<div className="container-xl"> <div className="container-xl">
<div className="page-header"> <div className="page-header">
@@ -230,7 +220,7 @@ function EasySwitchManager() {
Easy Switch Easy Switch
</h2> </h2>
<div className="text-muted mt-1"> <div className="text-muted mt-1">
Быстрое переключение шлюзов Быстрое переключение шлюзов для {servers.length} {servers.length === 1 ? 'сервера' : 'серверов'}
</div> </div>
</div> </div>
<div className="col-auto"> <div className="col-auto">
@@ -266,7 +256,7 @@ function EasySwitchManager() {
onClick={() => setActiveTab('proxies')} onClick={() => setActiveTab('proxies')}
type="button" type="button"
> >
Прокси ({communities.length}) Прокси ({servers.reduce((sum, s) => sum + s.communities.length, 0)})
</button> </button>
</li> </li>
<li className="nav-item" role="presentation"> <li className="nav-item" role="presentation">
@@ -343,123 +333,135 @@ function EasySwitchManager() {
</div> </div>
</div> </div>
{/* Список communities с карточками шлюзов */} {/* Список серверов с communities и шлюзами */}
{filteredCommunities.length === 0 ? ( {filteredServers.length === 0 ? (
<div className="card"> <div className="card">
<div className="card-body text-center py-5"> <div className="card-body text-center py-5">
<IconFilter size={48} className="text-muted mb-3" /> <IconFilter size={48} className="text-muted mb-3" />
<h3 className="text-muted">Ничего не найдено</h3> <h3 className="text-muted">Ничего не найдено</h3>
<p className="text-muted"> <p className="text-muted">
{searchQuery ? 'Попробуйте изменить критерии поиска' : 'Нет доступных communities'} {searchQuery ? 'Попробуйте изменить критерии поиска' : 'Нет доступных серверов'}
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{filteredCommunities.map((comm) => { {filteredServers.map((server) => {
const parsed = parseCommunity(comm.community); const serverMeta = getServerMetadata(server.name);
const activeGw = activeGateways[comm.community]; const country = serverMeta?.country || '';
const flag = countryToFlag(country);
return ( return (
<div key={comm.community} className="card mb-3"> <div key={server.id} className="card mb-3">
<div className="card-header py-2"> {/* Заголовок сервера */}
<div className="d-flex align-items-center justify-content-between"> <div className="card-header py-2" style={{ backgroundColor: 'rgba(32, 107, 196, 0.05)' }}>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.85rem' }}> {flag && <span className="me-2" style={{ fontSize: '1.5rem' }}>{flag}</span>}
{comm.community} <IconServer size={20} className="me-2 text-primary" />
</span> <h3 className="card-title mb-0 me-2">{server.name}</h3>
<span className="fw-bold me-2">{comm.description}</span> {server.description && (
<span className="text-muted small"> <span className="text-muted small"> {server.description}</span>
({comm.gateways.length}) )}
</span> <span className="badge bg-blue-lt text-blue ms-auto">
</div> {server.communities.length} {server.communities.length === 1 ? 'community' : 'communities'}
</span>
</div> </div>
</div> </div>
<div className="card-body py-2"> <div className="card-body p-3">
{/* Сетка карточек шлюзов */} {/* Communities для этого сервера */}
<div className="row g-2"> {server.communities.map((comm) => {
{comm.gateways.map((gw, idx) => { const activeKey = `${server.id}:${comm.community}`;
const isActive = activeGw === gw.gateway; const activeGw = activeGateways[activeKey];
const serverMeta = getServerMetadata(gw.serverName);
const country = serverMeta?.country || '';
const flag = countryToFlag(country);
const isFastest = idx === 0; // Первый считаем самым быстрым
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15); // Более реалистичный пинг
return ( return (
<div key={`${gw.gateway}-${gw.serverId}`} className="col-sm-6 col-md-4 col-lg-3"> <div key={comm.community} className="mb-3">
<div {/* Заголовок community */}
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`} <div className="mb-2">
style={{ <span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
cursor: 'pointer', {comm.community}
transition: 'all 0.15s ease', </span>
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)', <span className="fw-medium">{comm.description}</span>
borderRadius: '8px', <span className="text-muted small ms-2">
boxShadow: isActive ? '0 4px 12px rgba(32, 107, 196, 0.3)' : 'none' ({comm.gateways.length})
}} </span>
onClick={() => handleGatewaySelect(comm.community, gw.gateway)}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
e.currentTarget.style.transform = 'translateY(-2px)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}
}}
>
<div className="card-body p-3">
{/* Флаг и галочка */}
<div className="d-flex align-items-start justify-content-between mb-2">
{flag ? (
<span style={{ fontSize: '2rem', lineHeight: 1 }}>{flag}</span>
) : (
<IconServer size={32} className={isActive ? 'text-white' : 'text-muted'} />
)}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{ width: '24px', height: '24px' }}
>
<IconCheck size={16} />
</div>
)}
</div>
{/* Метка Fastest */}
{isFastest && !isActive && (
<div className="mb-2">
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center">
<IconBolt size={14} className="me-1" />
Fastest
</span>
</div>
)}
{/* Название шлюза */}
<div className={`fw-bold mb-1 ${isActive ? 'text-white' : ''}`} style={{ fontSize: '0.95rem' }}>
{flag && country ? `${country}` : gw.gateway}
</div>
{/* Протокол */}
<div className={`small mb-2 ${isActive ? 'text-white' : 'text-muted'}`} style={{ opacity: isActive ? 0.85 : 1 }}>
{serverMeta?.provider || 'vless / xudp'}
</div>
{/* Метрика (пинг) */}
<div className={`fw-bold ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.25rem' }}>
{ping}
</div>
</div>
</div>
</div> </div>
);
})} {/* Сетка карточек шлюзов */}
</div> <div className="row g-2">
{comm.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-6 col-sm-4 col-md-3 col-lg-2">
<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'
}}
onClick={() => handleGatewaySelect(server.id, comm.community, 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">
{/* Галочка и метка Fastest */}
<div className="d-flex align-items-start justify-content-between mb-1">
{isFastest && !isActive && (
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 4px' }}>
<IconBolt size={10} className="me-1" />
Fast
</span>
)}
{!isFastest && <span></span>}
{isActive && (
<div
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
style={{ width: '18px', height: '18px' }}
>
<IconCheck size={12} />
</div>
)}
</div>
{/* Название шлюза */}
<div className={`fw-bold mb-1 ${isActive ? 'text-white' : ''}`} style={{ fontSize: '0.8rem' }}>
{gw.name}
</div>
{/* Протокол */}
<div className={`small ${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: '1rem' }}>
{ping}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div> </div>
</div> </div>
); );