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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s
This commit is contained in:
+200
@@ -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
|
||||||
|
|
||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
IconFilter,
|
IconFilter,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconMenu2
|
IconMenu2,
|
||||||
|
IconArrowsExchange
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import ServerManager from './ServerManager';
|
import ServerManager from './ServerManager';
|
||||||
import FilterManager from './FilterManager';
|
import FilterManager from './FilterManager';
|
||||||
|
import EasySwitchManager from './EasySwitchManager';
|
||||||
import DomainsNewManager from './DomainsNewManager';
|
import DomainsNewManager from './DomainsNewManager';
|
||||||
import IPRangesManager from './IPRangesManager';
|
import IPRangesManager from './IPRangesManager';
|
||||||
import ASNsNewManager from './ASNsNewManager';
|
import ASNsNewManager from './ASNsNewManager';
|
||||||
@@ -49,12 +51,14 @@ function LanguageProvider({ children }) {
|
|||||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||||
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||||
|
easySwitch: 'Easy Switch',
|
||||||
light: 'Светлая', dark: 'Тёмная'
|
light: 'Светлая', dark: 'Тёмная'
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||||
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||||
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
||||||
|
easySwitch: 'Easy Switch',
|
||||||
light: 'Light', dark: 'Dark'
|
light: 'Light', dark: 'Dark'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -186,6 +190,7 @@ function MainLayout() {
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer },
|
{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer },
|
||||||
{ id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter },
|
{ 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 }
|
{ id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -392,6 +397,7 @@ function MainLayout() {
|
|||||||
<Route path="/servers" element={<ServerManager />} />
|
<Route path="/servers" element={<ServerManager />} />
|
||||||
<Route path="/billing" element={<BillingManager />} />
|
<Route path="/billing" element={<BillingManager />} />
|
||||||
<Route path="/filters" element={<FilterManager />} />
|
<Route path="/filters" element={<FilterManager />} />
|
||||||
|
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
Reference in New Issue
Block a user