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. **Визуальный интерфейс**
- Карточки серверов с флагами стран
- Компактные карточки шлюзов (уменьшенный размер)
- Карточки с флагами стран
- Отображение пинга и протокола подключения
- Метка "Fastest" для самого быстрого сервера в группе
- Активный сервер выделен синим цветом
- Метка "Fast" для самого быстрого шлюза в группе
- Активный шлюз выделен синим цветом
### 2. **Группировка по Communities**
- Все шлюзы сгруппированы по BGP communities
- Для каждого community можно выбрать активный шлюз
- Отображается количество доступных шлюзов
### 2. **Группировка по Серверам**
- Сервера группируются в отдельные блоки
- Каждый сервер показывает свои communities
- Для каждого community на сервере можно выбрать активный шлюз **этого сервера**
- Переключение происходит между шлюзами **внутри одного сервера**
- Отображается количество communities на каждом сервере
### 3. **Поиск и фильтрация**
- Поиск по 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`
### Переключение шлюза
1. Найдите нужный community в списке
2. Нажмите на карточку желаемого шлюза
3. Карточка станет синей (активной)
4. Нажмите "Применить изменения" для сохранения
1. Найдите нужный **сервер** в списке
2. В этом сервере найдите нужный **community**
3. Нажмите на карточку желаемого **шлюза этого сервера**
4. Карточка станет синей (активной)
5. Нажмите "Применить изменения" для сохранения
**Важно:** Переключение происходит между шлюзами **внутри одного сервера**. Каждый сервер имеет свой набор шлюзов для каждого community.
### Поиск
Введите в поле поиска:
- Название сервера (например, `SWE-HIPHOST`)
- Номер community (например, `65001:100`)
- Описание (например, `Стриминг`)
- Название шлюза (например, `SWE-HIPHOST`)
- Название шлюза (например, `gateway1`)
## Технические детали
@@ -124,12 +177,13 @@ GET /api/servers // Метаданные серверов
```javascript
- loading: boolean // Загрузка данных
- error: string // Текст ошибки
- servers: [] // Список серверов
- communities: [] // Группы communities
- activeGateways: {} // Активные шлюзы
- servers: [] // Список серверов с communities и gateways
- inventoryServers: [] // Метаданные серверов (флаги, провайдеры)
- activeGateways: {} // Активные шлюзы (ключ: "serverId:community")
- hasChanges: boolean // Наличие изменений
- searchQuery: string // Поисковый запрос
- activeTab: string // Активная вкладка
- selectedRule: string // Выбранное правило фильтрации
- activeTab: string // Активная вкладка (proxies/providers)
```
## Roadmap
+187 -185
View File
@@ -3,7 +3,6 @@ import api from './lib/api.js';
import {
IconSearch,
IconRefresh,
IconChevronDown,
IconCheck,
IconWorld,
IconServer,
@@ -15,8 +14,6 @@ import {
IconBolt
} from '@tabler/icons-react';
const API_URL = '/api';
// Функция для получения флага по коду страны
const countryToFlag = (code) => {
if (!code || typeof code !== 'string') return '';
@@ -26,20 +23,6 @@ const countryToFlag = (code) => {
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() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -47,8 +30,6 @@ function EasySwitchManager() {
// Серверы и фильтры
const [servers, setServers] = useState([]);
const [inventoryServers, setInventoryServers] = useState([]);
const [allFilters, setAllFilters] = useState([]); // Все фильтры со всех серверов
const [communities, setCommunities] = useState([]); // Список communities для отображения
// Поиск и фильтры
const [searchQuery, setSearchQuery] = useState('');
@@ -81,61 +62,63 @@ function EasySwitchManager() {
// Загружаем список серверов из server-configs
const serversResponse = await api.get(`/server-configs`);
const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : [];
setServers(serversList);
// Загружаем фильтры для каждого сервера
const filtersPromises = serversList.map(async (server) => {
try {
const response = await api.get(`/server-filters/${server.id}`);
const filters = Array.isArray(response.data) ? response.data : [];
return filters.map(f => ({
...f,
serverId: server.id,
serverName: server.name
}));
} catch (e) {
console.error(`Error loading filters for ${server.id}:`, e);
return [];
}
});
const serversWithFilters = await Promise.all(
serversList.map(async (server) => {
try {
const response = await api.get(`/server-filters/${server.id}`);
const filters = Array.isArray(response.data) ? response.data : [];
// Группируем фильтры по community внутри сервера
const communitiesMap = new Map();
filters.forEach(filter => {
const comm = filter.community;
if (!communitiesMap.has(comm)) {
communitiesMap.set(comm, {
community: comm,
description: filter.description || '',
gateways: []
});
}
const communityData = communitiesMap.get(comm);
// Добавляем gateway, если его еще нет
if (!communityData.gateways.find(g => g.name === filter.gateway)) {
communityData.gateways.push({
name: filter.gateway,
description: filter.description
});
}
});
return {
...server,
communities: Array.from(communitiesMap.values())
};
} catch (e) {
console.error(`Error loading filters for ${server.id}:`, e);
return {
...server,
communities: []
};
}
})
);
const filtersArrays = await Promise.all(filtersPromises);
const combinedFilters = filtersArrays.flat();
setAllFilters(combinedFilters);
// Фильтруем серверы, у которых есть communities
const serversWithCommunities = serversWithFilters.filter(s => s.communities.length > 0);
setServers(serversWithCommunities);
// Группируем по community
const communityMap = new Map();
combinedFilters.forEach(filter => {
const comm = filter.community;
if (!communityMap.has(comm)) {
communityMap.set(comm, {
community: comm,
description: filter.description || '',
gateways: []
});
}
const communityData = communityMap.get(comm);
// Добавляем gateway, если его еще нет
if (!communityData.gateways.find(g => g.gateway === filter.gateway && g.serverId === filter.serverId)) {
communityData.gateways.push({
gateway: filter.gateway,
serverId: filter.serverId,
serverName: filter.serverName,
description: filter.description
});
}
});
const communitiesArray = Array.from(communityMap.values());
setCommunities(communitiesArray);
// Устанавливаем активные шлюзы (первый для каждого community)
// Устанавливаем активные шлюзы (первый gateway для каждого community на каждом сервере)
const initialActive = {};
communitiesArray.forEach(comm => {
if (comm.gateways.length > 0) {
initialActive[comm.community] = comm.gateways[0].gateway;
}
serversWithCommunities.forEach(server => {
server.communities.forEach(comm => {
if (comm.gateways.length > 0) {
const key = `${server.id}:${comm.community}`;
initialActive[key] = comm.gateways[0].name;
}
});
});
setActiveGateways(initialActive);
@@ -147,10 +130,11 @@ function EasySwitchManager() {
}
};
const handleGatewaySelect = (community, gateway) => {
const handleGatewaySelect = (serverId, community, gateway) => {
const key = `${serverId}:${community}`;
setActiveGateways(prev => ({
...prev,
[community]: gateway
[key]: gateway
}));
setHasChanges(true);
};
@@ -176,23 +160,29 @@ function EasySwitchManager() {
);
};
// Фильтрация communities по поиску
const filteredCommunities = communities.filter(comm => {
// Фильтрация серверов по поиску
const filteredServers = servers.filter(server => {
if (!searchQuery.trim()) return true;
const search = searchQuery.toLowerCase();
const commValue = (comm.community || '').toLowerCase();
const desc = (comm.description || '').toLowerCase();
const serverName = (server.name || '').toLowerCase();
const serverDesc = (server.description || '').toLowerCase();
return commValue.includes(search) ||
desc.includes(search) ||
comm.gateways.some(g =>
(g.gateway || '').toLowerCase().includes(search) ||
(g.serverName || '').toLowerCase().includes(search)
);
// Поиск по имени/описанию сервера
if (serverName.includes(search) || serverDesc.includes(search)) return true;
// Поиск по communities и gateways
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 (
<div className="container-xl">
<div className="page-header">
@@ -230,7 +220,7 @@ function EasySwitchManager() {
Easy Switch
</h2>
<div className="text-muted mt-1">
Быстрое переключение шлюзов
Быстрое переключение шлюзов для {servers.length} {servers.length === 1 ? 'сервера' : 'серверов'}
</div>
</div>
<div className="col-auto">
@@ -266,7 +256,7 @@ function EasySwitchManager() {
onClick={() => setActiveTab('proxies')}
type="button"
>
Прокси ({communities.length})
Прокси ({servers.reduce((sum, s) => sum + s.communities.length, 0)})
</button>
</li>
<li className="nav-item" role="presentation">
@@ -343,123 +333,135 @@ function EasySwitchManager() {
</div>
</div>
{/* Список communities с карточками шлюзов */}
{filteredCommunities.length === 0 ? (
{/* Список серверов с 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 ? 'Попробуйте изменить критерии поиска' : 'Нет доступных communities'}
{searchQuery ? 'Попробуйте изменить критерии поиска' : 'Нет доступных серверов'}
</p>
</div>
</div>
) : (
<div className="space-y-3">
{filteredCommunities.map((comm) => {
const parsed = parseCommunity(comm.community);
const activeGw = activeGateways[comm.community];
{filteredServers.map((server) => {
const serverMeta = getServerMetadata(server.name);
const country = serverMeta?.country || '';
const flag = countryToFlag(country);
return (
<div key={comm.community} className="card mb-3">
<div className="card-header py-2">
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.85rem' }}>
{comm.community}
</span>
<span className="fw-bold me-2">{comm.description}</span>
<span className="text-muted small">
({comm.gateways.length})
</span>
</div>
<div key={server.id} className="card mb-3">
{/* Заголовок сервера */}
<div className="card-header py-2" style={{ backgroundColor: 'rgba(32, 107, 196, 0.05)' }}>
<div className="d-flex align-items-center">
{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">
{server.communities.length} {server.communities.length === 1 ? 'community' : 'communities'}
</span>
</div>
</div>
<div className="card-body py-2">
{/* Сетка карточек шлюзов */}
<div className="row g-2">
{comm.gateways.map((gw, idx) => {
const isActive = activeGw === gw.gateway;
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 (
<div key={`${gw.gateway}-${gw.serverId}`} className="col-sm-6 col-md-4 col-lg-3">
<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: '8px',
boxShadow: isActive ? '0 4px 12px rgba(32, 107, 196, 0.3)' : 'none'
}}
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 className="card-body p-3">
{/* Communities для этого сервера */}
{server.communities.map((comm) => {
const activeKey = `${server.id}:${comm.community}`;
const activeGw = activeGateways[activeKey];
return (
<div key={comm.community} className="mb-3">
{/* Заголовок community */}
<div className="mb-2">
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
{comm.community}
</span>
<span className="fw-medium">{comm.description}</span>
<span className="text-muted small ms-2">
({comm.gateways.length})
</span>
</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>
);