import { useEffect, useMemo, useState } from 'react'; import api from './lib/api.js'; import { IconPlus, IconSearch, IconEdit, IconTrash, IconCheck, IconX, IconHash, IconFilter, IconChartBar, IconList, IconRefresh, IconSortAscending, IconSortDescending, IconUpload, IconDownload, IconDeviceFloppy, IconPlugConnected } from '@/lib/icons'; import PageHeader from './components/PageHeader.jsx'; import PortalDropdown from './components/PortalDropdown.jsx'; import EmptyState from './components/EmptyState.jsx'; import CommunityStats from './components/CommunityStats.jsx'; import SavedFilters from './components/SavedFilters.jsx'; import FormModal from './components/FormModal.jsx'; import ConfirmModal from './components/ConfirmModal.jsx'; import FormField from './components/FormField.jsx'; import { ErrorAlert } from '@/components/shared/feedback'; import Pagination from './components/Pagination.jsx'; import IconPicker from './components/IconPicker.jsx'; import { getIconById } from './lib/brandIcons.js'; import { listCommunities, saveCommunitiesData, normalizeEvobgpApiBaseUrl } from './lib/evobgpData.js'; const normalizeCommunityValue = (value) => { const trimmed = String(value || '').trim(); if (!trimmed) return ''; // Если ASN не указан, используем 65001 по умолчанию return trimmed.includes(':') ? trimmed : `65001:${trimmed}`; }; function CommunitiesManager() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [sortField, setSortField] = useState('value'); const [sortOrder, setSortOrder] = useState('asc'); const [currentPage, setCurrentPage] = useState(1); const pageSize = 10; // Состояние для переключения между списком и статистикой const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats' const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true }); const [editingValue, setEditingValue] = useState(null); const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true }); const [filterUsage, setFilterUsage] = useState({}); const [filterUsageLoading, setFilterUsageLoading] = useState(false); const [filterUsageError, setFilterUsageError] = useState(''); const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false); const [evobgpUrlDraft, setEvobgpUrlDraft] = useState(''); const [evobgpTokenDraft, setEvobgpTokenDraft] = useState(''); const [evobgpSettingsOpen, setEvobgpSettingsOpen] = useState(() => { if (import.meta.env?.VITE_EVOBGP_API_URL) return false; try { return Boolean(window.localStorage?.getItem('evobgp_api_url')); } catch { return false; } }); // Модальные окна const [addModalOpen, setAddModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); useEffect(() => { fetchItems(); fetchFilterUsage(); }, []); useEffect(() => { try { setEvobgpUrlDraft(String(window.localStorage?.getItem('evobgp_api_url') || '')); setEvobgpTokenDraft(String(window.localStorage?.getItem('evobgp_api_token') || '')); } catch { /* no-op */ } }, []); useEffect(() => { if (!filterUsage) return; setItems((prev) => { const map = new Map(prev.map((i) => [i.value, i])); Object.keys(filterUsage).forEach((value) => { if (!value) return; if (!map.has(value)) { map.set(value, { value, name: '', description: '', tags: [], color: '', icon: '', _external: true }); } }); return Array.from(map.values()); }); }, [filterUsage]); const fetchItems = async () => { setLoading(true); try { const rows = await listCommunities(); const normalized = rows.map((item) => ({ ...item, value: normalizeCommunityValue(item.value), })); setItems(normalized); setError(''); } catch (e) { console.error('Error fetching communities:', e); setError('Не удалось загрузить справочник community.'); } finally { setLoading(false); } }; const fetchFilterUsage = async () => { setFilterUsageLoading(true); setFilterUsageError(''); try { const serversRes = await api.get(`/server-configs`); const serversList = Array.isArray(serversRes.data) ? serversRes.data : []; if (!serversList.length) { setFilterUsage({}); return; } const responses = await Promise.all( serversList.map((server) => api.get(`/server-filters/${server.id}`).catch(() => ({ data: [] })) ) ); const usageMap = new Map(); responses.forEach((res) => { const arr = Array.isArray(res.data) ? res.data : []; arr.forEach((f) => { const value = normalizeCommunityValue(f?.community); if (!value) return; usageMap.set(value, (usageMap.get(value) || 0) + 1); }); }); setFilterUsage(Object.fromEntries(usageMap)); } catch (e) { console.error('Error fetching filter usage:', e); setFilterUsageError('Не удалось загрузить использование в фильтрах.'); } finally { setFilterUsageLoading(false); } }; const saveAll = async (data) => { setLoading(true); try { await saveCommunitiesData(data); setSuccess('Справочник сохранён!'); setTimeout(() => setSuccess(''), 3000); } catch (e) { console.error('Error saving communities:', e); setError(e.response?.data || 'Не удалось сохранить справочник.'); } finally { setLoading(false); } }; const toTagsArray = (str) => (str || '').split(',').map(t => t.trim()).filter(Boolean); const fromTagsArray = (arr) => (arr || []).join(', '); const addItem = () => { const value = normalizeCommunityValue(newItem.value); if (!value) { setError('Поле value обязательно.'); return; } if (!/^(\d+|\d+:\d+)$/.test(value)) { setError('Формат: число или N:N'); return; } if (items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; } setError(''); setItems([...items, { value, name: String(newItem.name || ''), description: String(newItem.description || ''), tags: toTagsArray(newItem.tags), color: String(newItem.color || ''), icon: String(newItem.icon || ''), }]); setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' }); setAddModalOpen(false); setSuccess('Community успешно добавлен!'); setTimeout(() => setSuccess(''), 3000); }; const startEdit = (item) => { setEditingValue(item.value); setEditingDraft({ value: item.value, name: item.name || '', description: item.description || '', tags: fromTagsArray(item.tags), color: item.color || '', icon: item.icon || '', }); setEditModalOpen(true); }; const saveEdit = () => { const value = normalizeCommunityValue(editingDraft.value); if (!value) { setError('Поле value обязательно.'); return; } if (!/^(\d+|\d+:\d+)$/.test(value)) { setError('Формат: число или N:N'); return; } if (value !== editingValue && items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; } const next = items.map(i => i.value === editingValue ? { value, name: String(editingDraft.name || ''), description: String(editingDraft.description || ''), tags: toTagsArray(editingDraft.tags), color: String(editingDraft.color || ''), icon: String(editingDraft.icon || ''), } : i); setItems(next); setEditingValue(null); setEditModalOpen(false); setSuccess('Community успешно обновлён!'); setTimeout(() => setSuccess(''), 3000); }; const cancelEdit = () => { setEditingValue(null); setEditModalOpen(false); }; const handleDeleteClick = (item) => { setItemToDelete(item); setDeleteModalOpen(true); }; const confirmDelete = () => { if (itemToDelete) { setItems(items.filter(i => i.value !== itemToDelete.value)); setDeleteModalOpen(false); setItemToDelete(null); setSuccess('Community успешно удалён!'); setTimeout(() => setSuccess(''), 3000); } }; const handleImport = () => { const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, color, icon)'); if (!text) return; try { const arr = JSON.parse(text); if (!Array.isArray(arr)) throw new Error('Ожидается массив'); setItems(prev => { const map = new Map(prev.map(i => [i.value, i])); arr.forEach(x => { if (!x || !x.value) return; const norm = normalizeCommunityValue(x.value); if (!norm) return; if (map.has(norm)) return; map.set(norm, { value: norm, name: String(x.name || ''), description: String(x.description || ''), tags: Array.isArray(x.tags) ? x.tags : [], color: String(x.color || ''), icon: String(x.icon || ''), }); }); return Array.from(map.values()); }); } catch (_e) { setError('Неверный JSON.'); } }; const handleExport = () => { const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `communities_${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); }; const handleSave = () => saveAll(items); const saveEvobgpConnection = () => { const trimmedUrl = String(evobgpUrlDraft || '').trim(); if (!trimmedUrl) { try { window.localStorage.removeItem('evobgp_api_url'); window.localStorage.removeItem('evobgp_api_token'); } catch (_e) { setError('Не удалось очистить настройки в localStorage.'); return; } setEvobgpUrlDraft(''); setEvobgpTokenDraft(''); setError(''); setSuccess('Включён серверный прокси /api/evobgp (EVOBGP_API_URL и EVOBGP_API_TOKEN в контейнере).'); fetchItems(); setTimeout(() => setSuccess(''), 5000); return; } const norm = normalizeEvobgpApiBaseUrl(trimmedUrl); if (!norm) { setError('Укажите корректный URL EvoBGP (например http://192.168.0.1:8080). Порт — у API EvoBGP, не у этого интерфейса.'); return; } try { window.localStorage.setItem('evobgp_api_url', norm); window.localStorage.setItem('evobgp_api_token', String(evobgpTokenDraft || '').trim()); } catch (_e) { setError('Не удалось сохранить настройки в localStorage.'); return; } setEvobgpUrlDraft(norm); setError(''); setSuccess('Прямое подключение к EvoBGP из браузера сохранено. Загружаем данные…'); fetchItems(); setTimeout(() => setSuccess(''), 4000); }; const sorted = useMemo(() => { const arr = [...items]; arr.sort((a, b) => { let va = (a[sortField] ?? '').toString().toLowerCase(); let vb = (b[sortField] ?? '').toString().toLowerCase(); if (va < vb) return sortOrder === 'asc' ? -1 : 1; if (va > vb) return sortOrder === 'asc' ? 1 : -1; return 0; }); return arr; }, [items, sortField, sortOrder]); const filtered = useMemo(() => { const q = searchTerm.trim().toLowerCase(); const searchFiltered = q ? sorted.filter(i => (i.value || '').toLowerCase().includes(q) || (i.name || '').toLowerCase().includes(q) || (i.description || '').toLowerCase().includes(q) || (i.tags || []).some(t => (t || '').toLowerCase().includes(q)) ) : sorted; return searchFiltered.filter((i) => !showOnlyUsedInFilters || (filterUsage[i.value] || 0) > 0); }, [sorted, searchTerm, showOnlyUsedInFilters, filterUsage]); const totalPages = Math.ceil(filtered.length / pageSize) || 1; const page = Math.min(currentPage, totalPages); const paginated = filtered.slice((page - 1) * pageSize, page * pageSize); const changeSort = (f) => { if (sortField === f) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); else { setSortField(f); setSortOrder('asc'); } }; return (
} />
Подключение к EvoBGP API
{evobgpSettingsOpen && (

По умолчанию запросы идут на внутренний прокси /api/evobgp: URL и токен задаются в{' '} переменных окружения контейнераEVOBGP_API_URL и EVOBGP_API_TOKEN{' '} (без CORS). Ниже — только опциональное прямое подключение из браузера к EvoBGP; оставьте URL пустым и нажмите «Сохранить», чтобы снова использовать прокси.

setEvobgpUrlDraft(e.target.value)} />
setEvobgpTokenDraft(e.target.value)} />
)}
{/* Уведомления */} {error && ( setError('')} onRetry={fetchItems} /> )} {success && (
{success}
)} {/* Табы и кнопки в одной строке, единый стиль btn-primary */}
Импорт/Экспорт} >
{/* Отображение списка communities */} {activeTab === 'list' && (

Список community {filtered.length}

{ if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm); if (filters.sortField !== undefined) setSortField(filters.sortField); if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder); setCurrentPage(1); }} />
{setSearchTerm(e.target.value); setCurrentPage(1);}} style={{ paddingRight: searchTerm ? '2.5rem' : undefined }} /> {!!searchTerm && ( )}
{filterUsageError && ( {filterUsageError} )}
{loading ? ( ) : paginated.length === 0 ? ( ) : paginated.map((item) => { const usageCount = filterUsage[item.value] || 0; const iconInfo = item.icon ? getIconById(item.icon) : null; return ( ); })}
Иконка changeSort('value')}>
Value {sortField==='value' && ( {sortOrder==='asc' ? : } )}
changeSort('name')}>
Название {sortField==='name' && ( {sortOrder==='asc' ? : } )}
Описание Теги Исп. в фильтрах Действия
Загрузка...
setAddModalOpen(true)}> Добавить } />
{iconInfo ? ( ) : ( )} {item.value} {item._external && ( не в справ. )} {item.name || } {item.description || '—'} {(item.tags || []).map(t => ( {t} ))} {usageCount > 0 ? ( {usageCount} в фильтрах ) : ( 0 )}
{totalPages > 1 && ( )}
)} {/* Отображение статистики communities */} {activeTab === 'stats' && ( )} {/* Модальное окно добавления */} { setAddModalOpen(false); setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' }); setError(''); }} onSubmit={addItem} title="Добавить community" submitLabel="Добавить" submitIcon={IconPlus} size="md" > {error && (
{error}
)} setNewItem({...newItem, value: val})} placeholder="65001:200 или 200 (без указания AS)" required icon={IconHash} helpText="Формат: число (например, 200) или N:N (например, 65001:200). Без ASN автоматически станет 65001:XXX." onValidate={(val) => { const trimmed = String(val || '').trim(); if (!trimmed) return { valid: false, message: 'Поле обязательно' }; if (!/^(\d+|\d+:\d+)$/.test(trimmed)) return { valid: false, message: 'Формат: 200 или 65001:200' }; const normalized = normalizeCommunityValue(trimmed); if (items.some(i => i.value === normalized)) return { valid: false, message: 'Такое value уже существует' }; return { valid: true, message: `Будет сохранено как ${normalized}` }; }} /> setNewItem({...newItem, name: val})} placeholder="Напр. Social" icon={IconFilter} /> setNewItem({...newItem, description: val})} rows={2} /> setNewItem({...newItem, tags: val})} placeholder="video,bank" helpText="Разделяйте теги запятыми" /> setNewItem({...newItem, color: val})} placeholder="blue, green, red ..." helpText="Цвет для отображения бейджа в интерфейсе" /> setNewItem({...newItem, icon: id})} label="Иконка бренда (опционально)" allowNone />
{/* Модальное окно редактирования */} {error && (
{error}
)} setEditingDraft({...editingDraft, value: val})} placeholder="65001:200 или 200 (без указания AS)" required icon={IconHash} helpText="Формат: число (например, 200) или N:N (например, 65001:200). Без ASN автоматически станет 65001:XXX." onValidate={(val) => { const trimmed = String(val || '').trim(); if (!trimmed) return { valid: false, message: 'Поле обязательно' }; if (!/^(\d+|\d+:\d+)$/.test(trimmed)) return { valid: false, message: 'Формат: 200 или 65001:200' }; const normalized = normalizeCommunityValue(trimmed); if (normalized !== editingValue && items.some(i => i.value === normalized)) { return { valid: false, message: 'Такое value уже существует' }; } return { valid: true, message: `Будет сохранено как ${normalized}` }; }} /> setEditingDraft({...editingDraft, name: val})} placeholder="Напр. Social" icon={IconFilter} /> setEditingDraft({...editingDraft, description: val})} rows={2} /> setEditingDraft({...editingDraft, tags: val})} placeholder="video,bank" helpText="Разделяйте теги запятыми" /> setEditingDraft({...editingDraft, color: val})} placeholder="blue, green, red ..." helpText="Цвет для отображения бейджа в интерфейсе" /> setEditingDraft({...editingDraft, icon: id})} label="Иконка бренда (опционально)" allowNone />
{/* Модальное окно подтверждения удаления */} { setDeleteModalOpen(false); setItemToDelete(null); }} onConfirm={confirmDelete} title="Удалить community?" message={ itemToDelete ? ( <>

Вы уверены, что хотите удалить community {itemToDelete.value}?

{itemToDelete.name && (

Название: {itemToDelete.name}

)}

Это действие необратимо.

) : null } confirmLabel="Удалить" variant="danger" icon={IconTrash} />
); } export default CommunitiesManager;