feat: Add Easy Switch feature to the application, including new route and menu item for improved navigation and user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s

This commit is contained in:
2025-12-07 04:23:29 +07:00
parent 2859947a34
commit 7091660b80
3 changed files with 681 additions and 1 deletions
+200
View File
@@ -0,0 +1,200 @@
# Easy Switch - Быстрое переключение шлюзов
## Описание
Easy Switch - это новая страница для быстрого переключения шлюзов (gateways) на основе communities из фильтров. Интерфейс вдохновлен мобильными VPN приложениями и обеспечивает удобный способ управления маршрутизацией.
## Особенности
### 1. **Визуальный интерфейс**
- Карточки серверов с флагами стран
- Отображение пинга и протокола подключения
- Метка "Fastest" для самого быстрого сервера в группе
- Активный сервер выделен синим цветом
### 2. **Группировка по Communities**
- Все шлюзы сгруппированы по BGP communities
- Для каждого community можно выбрать активный шлюз
- Отображается количество доступных шлюзов
### 3. **Поиск и фильтрация**
- Поиск по community, описанию или имени шлюза
- Фильтрация по правилам
- Вкладки "Прокси" и "Провайдер прокси"
### 4. **Управление изменениями**
- Кнопка "Применить изменения" появляется при выборе нового шлюза
- Возможность обновить данные с сервера
- Отслеживание изменений
## Структура данных
### Источники данных
1. **Server Configs** (`/api/server-configs`)
- Список серверов с настройками
- ID сервера для загрузки фильтров
2. **Server Filters** (`/api/server-filters/{serverId}`)
- Фильтры для каждого сервера
- Community и gateway для каждого фильтра
3. **Inventory Servers** (`/api/servers`)
- Метаданные серверов (страна, провайдер)
- Используется для отображения флагов
### Формат данных фильтра
```json
{
"community": "65001:100",
"gateway": "SWE-HIPHOST",
"description": "Стриминг сервисы"
}
```
## Интерфейс
### Заголовок
- Название страницы "Easy Switch"
- Кнопка "Обновить" для перезагрузки данных
- Кнопка "Применить изменения" (появляется при наличии изменений)
### Вкладки
- **Прокси** - основной список communities с шлюзами
- **Провайдер прокси** - дополнительная вкладка (в разработке)
### Панель фильтров
- Dropdown "Правило" - фильтрация по типу правила
- Поле поиска - поиск по ключевым словам
- Кнопки инструментов (настройки, быстрые действия)
### Карточки серверов
Каждая карточка содержит:
- 🏴 **Флаг страны** (если доступен)
-**Метка Fastest** (для самого быстрого сервера)
- **Название** шлюза или страны
- **Протокол** подключения (vless/xudp и т.д.)
- **Пинг** в миллисекундах
-**Галочка** для активного сервера
### Состояния карточки
- **Неактивная** - белый фон, серая рамка
- **Активная** - синий фон, белый текст, тень
- **Hover** - подъем карточки, легкая тень
## Использование
### Открытие страницы
Навигация: `Управление → Easy Switch`
URL: `/easy-switch`
### Переключение шлюза
1. Найдите нужный community в списке
2. Нажмите на карточку желаемого шлюза
3. Карточка станет синей (активной)
4. Нажмите "Применить изменения" для сохранения
### Поиск
Введите в поле поиска:
- Номер community (например, `65001:100`)
- Описание (например, `Стриминг`)
- Название шлюза (например, `SWE-HIPHOST`)
## Технические детали
### Компоненты
- **EasySwitchManager.jsx** - главный компонент страницы
### Зависимости
- React hooks (useState, useEffect)
- Tabler Icons для иконок
- API клиент для загрузки данных
### API Endpoints
```javascript
GET /api/server-configs // Список серверов
GET /api/server-filters/{id} // Фильтры сервера
GET /api/servers // Метаданные серверов
```
### Состояния
```javascript
- loading: boolean // Загрузка данных
- error: string // Текст ошибки
- servers: [] // Список серверов
- communities: [] // Группы communities
- activeGateways: {} // Активные шлюзы
- hasChanges: boolean // Наличие изменений
- searchQuery: string // Поисковый запрос
- activeTab: string // Активная вкладка
```
## Roadmap
### Планируется добавить
- [ ] Реальные метрики пинга (интеграция с мониторингом)
- [ ] Сохранение изменений на сервер
- [ ] История переключений
- [ ] Групповое переключение
- [ ] Автоматический выбор fastest
- [ ] Уведомления о недоступных серверах
- [ ] Статистика использования
- [ ] Экспорт конфигурации
### В разработке
- [ ] Вкладка "Провайдер прокси"
- [ ] Настройки отображения
- [ ] Быстрые действия
## Отладка
### Проверка данных в консоли
```javascript
// В DevTools Console
console.log('Active Gateways:', activeGateways);
console.log('Communities:', communities);
console.log('All Filters:', allFilters);
```
### Частые проблемы
**Карточки не отображаются**
- Проверьте, что есть серверы в `/api/server-configs`
- Убедитесь, что для серверов есть фильтры в `/api/server-filters/{id}`
**Нет флагов стран**
- Проверьте поле `country` в `/api/servers`
- Должно быть двухбуквенным кодом (US, DE, RU и т.д.)
**Изменения не сохраняются**
- Функция `handleApplyChanges` требует доработки
- Пока что изменения только в локальном состоянии
## Стилизация
Страница использует:
- **Tabler Admin** - основной UI framework
- **Bootstrap 5** - сетка и утилиты
- **Custom styles** - для карточек и анимаций
### Цветовая схема
- Синий (`bg-primary`) - активный сервер
- Желтый (`bg-yellow-lt`) - fastest метка
- Зеленый (`text-success`) - пинг
- Синий светлый (`bg-blue-lt`) - badge community
## Производительность
- Загрузка данных выполняется параллельно
- Фильтрация происходит на клиенте
- Минимум перерисовок при взаимодействии
---
**Версия документа:** 1.0
**Дата создания:** 2025-12-06
**Автор:** AI Assistant
+7 -1
View File
@@ -19,10 +19,12 @@ import {
IconFilter,
IconDownload,
IconCreditCard,
IconMenu2
IconMenu2,
IconArrowsExchange
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
import EasySwitchManager from './EasySwitchManager';
import DomainsNewManager from './DomainsNewManager';
import IPRangesManager from './IPRangesManager';
import ASNsNewManager from './ASNsNewManager';
@@ -49,12 +51,14 @@ function LanguageProvider({ children }) {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
easySwitch: 'Easy Switch',
light: 'Светлая', dark: 'Тёмная'
},
en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
easySwitch: 'Easy Switch',
light: 'Light', dark: 'Dark'
}
};
@@ -186,6 +190,7 @@ function MainLayout() {
items: [
{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer },
{ id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter },
{ id: 'easy-switch', title: t('easySwitch'), path: '/easy-switch', icon: IconArrowsExchange },
{ id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard }
]
},
@@ -392,6 +397,7 @@ function MainLayout() {
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
+474
View File
@@ -0,0 +1,474 @@
import { useState, useEffect } from 'react';
import api from './lib/api.js';
import {
IconSearch,
IconRefresh,
IconChevronDown,
IconCheck,
IconWorld,
IconServer,
IconFilter,
IconAlertCircle,
IconX,
IconSettings,
IconDeviceFloppy,
IconBolt
} from '@tabler/icons-react';
const API_URL = '/api';
// Функция для получения флага по коду страны
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);
};
// Парсинг 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('');
// Серверы и фильтры
const [servers, setServers] = useState([]);
const [inventoryServers, setInventoryServers] = useState([]);
const [allFilters, setAllFilters] = useState([]); // Все фильтры со всех серверов
const [communities, setCommunities] = useState([]); // Список communities для отображения
// Поиск и фильтры
const [searchQuery, setSearchQuery] = useState('');
const [selectedRule, setSelectedRule] = useState('all'); // all | by-community
const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
// Активные шлюзы для каждого community (ключ: community, значение: gateway name)
const [activeGateways, setActiveGateways] = useState({});
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
loadData();
loadInventoryServers();
}, []);
const loadInventoryServers = async () => {
try {
const res = await api.get(`/servers`);
setInventoryServers(Array.isArray(res.data) ? res.data : []);
} catch (e) {
console.error('Error loading inventory servers:', e);
}
};
const loadData = async () => {
setLoading(true);
setError('');
try {
// Загружаем список серверов из 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 filtersArrays = await Promise.all(filtersPromises);
const combinedFilters = filtersArrays.flat();
setAllFilters(combinedFilters);
// Группируем по 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)
const initialActive = {};
communitiesArray.forEach(comm => {
if (comm.gateways.length > 0) {
initialActive[comm.community] = comm.gateways[0].gateway;
}
});
setActiveGateways(initialActive);
} catch (err) {
console.error('Error loading data:', err);
setError('Ошибка загрузки данных. Проверьте подключение к серверу.');
} finally {
setLoading(false);
}
};
const handleGatewaySelect = (community, gateway) => {
setActiveGateways(prev => ({
...prev,
[community]: gateway
}));
setHasChanges(true);
};
const handleApplyChanges = async () => {
// TODO: Реализовать сохранение изменений на сервер
console.log('Applying changes:', activeGateways);
setHasChanges(false);
// Здесь можно добавить логику обновления фильтров на серверах
};
const handleRefresh = () => {
loadData();
loadInventoryServers();
};
// Получаем метаданные сервера из inventory
const getServerMetadata = (serverName) => {
return inventoryServers.find(s =>
s.dns === serverName ||
s.hostName === serverName ||
s.ip === serverName
);
};
// Фильтрация communities по поиску
const filteredCommunities = communities.filter(comm => {
if (!searchQuery.trim()) return true;
const search = searchQuery.toLowerCase();
const commValue = (comm.community || '').toLowerCase();
const desc = (comm.description || '').toLowerCase();
return commValue.includes(search) ||
desc.includes(search) ||
comm.gateways.some(g =>
(g.gateway || '').toLowerCase().includes(search) ||
(g.serverName || '').toLowerCase().includes(search)
);
});
if (loading && communities.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">
Быстрое переключение шлюзов
</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"
>
Прокси ({communities.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 d-flex align-items-center" role="alert">
<IconAlertCircle className="me-2" />
<div>{error}</div>
</div>
)}
{/* Панель фильтров */}
<div className="mb-3">
<div className="row g-2">
{/* Фильтр по правилу */}
<div className="col-md-3">
<select
className="form-select"
value={selectedRule}
onChange={(e) => setSelectedRule(e.target.value)}
>
<option value="all">Правило</option>
<option value="by-community">По community</option>
</select>
</div>
{/* Поиск */}
<div className="col-md-6">
<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 ms-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 с карточками шлюзов */}
{filteredCommunities.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'}
</p>
</div>
</div>
) : (
<div className="space-y-3">
{filteredCommunities.map((comm) => {
const parsed = parseCommunity(comm.community);
const activeGw = activeGateways[comm.community];
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>
</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>
);
})}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
export default EasySwitchManager;