import { useState, useEffect, useRef, useMemo } from 'react'; import api from './lib/api.js'; import { IconPlus, IconTrash, IconDownload, IconAlertCircle, IconCheck, IconUpload, IconDeviceFloppy, IconInfoCircle, IconCircleCheck, IconCopy, IconSearch, IconLink, IconHash, IconWorld, IconFileExport, IconSortAscending, IconSortDescending, IconX } from '@tabler/icons-react'; import PageHeader from './components/PageHeader.jsx'; import TableSkeleton from './components/TableSkeleton.jsx'; import EmptyState from './components/EmptyState.jsx'; import ImportModal from './components/ImportModal.jsx'; import ErrorAlert from './components/ErrorAlert.jsx'; import LastSaved from './components/LastSaved.jsx'; function AutoUrlManager() { const [urls, setUrls] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [processing, setProcessing] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [touched, setTouched] = useState({}); const [importOpen, setImportOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [filterValid, setFilterValid] = useState('all'); // 'all' | 'valid' | 'invalid' const [sortField, setSortField] = useState(null); // 'url' | 'community' | null const [sortDir, setSortDir] = useState('asc'); // 'asc' | 'desc' const [lastSaved, setLastSaved] = useState(null); const [selectedRows, setSelectedRows] = useState(new Set()); const searchInputRef = useRef(null); useEffect(() => { fetchUrls(); }, []); const fetchUrls = async () => { try { setLoading(true); setError(''); const response = await api.get('/auto-urls'); setUrls(response.data || []); } catch (err) { console.error('Error fetching URLs:', err); setError('Ошибка при загрузке URL-адресов'); } finally { setLoading(false); } }; const addUrl = () => { setUrls([...urls, { url: '', community: '' }]); }; const clearEmptyRows = () => { setUrls(urls.filter(u => u != null && (u.url || u.community))); }; const removeUrl = (index) => { setUrls(urls.filter((_, i) => i !== index)); }; const updateUrl = (index, field, value) => { const newUrls = [...urls]; newUrls[index][field] = value; setUrls(newUrls); setTouched(prev => ({ ...prev, [index]: true })); }; const isValidHttpUrl = (value) => { try { const u = new URL(value); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; } }; const isValidCommunity = (value) => { return /^[0-9]+$/.test(String(value).trim()); }; const getRowValidity = (row) => ({ url: row.url ? isValidHttpUrl(row.url) : false, community: row.community ? isValidCommunity(row.community) : false }); const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community)); const saveUrls = async () => { try { setSaving(true); setError(''); setSuccess(''); // Validate URLs (пустой список тоже валиден - для очистки) const validUrls = urls .filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)) .map(u => ({ url: u.url.trim(), community: String(u.community).trim() })); await api.post('/auto-urls', { urls: validUrls }); setUrls(validUrls); setLastSaved(new Date().toISOString()); setSuccess(validUrls.length === 0 ? 'Список URL очищен' : 'URL-адреса сохранены успешно'); setSelectedRows(new Set()); setTimeout(() => setSuccess(''), 3000); } catch (err) { console.error('Error saving URLs:', err); setError('Ошибка при сохранении URL-адресов'); } finally { setSaving(false); } }; const processUrls = async () => { try { setProcessing(true); setError(''); setSuccess(''); const response = await api.post('/auto-urls/process'); setSuccess(response.data?.message || 'URL-адреса успешно обработаны'); setTimeout(() => setSuccess(''), 5000); } catch (err) { console.error('Error processing URLs:', err); setError(err.response?.data?.message || err.response?.data || 'Ошибка при обработке URL-адресов'); } finally { setProcessing(false); } }; const copyExample = async () => { const text = 'https://test.com/ips.txt 555\nhttps://example.com/blacklist.txt 666'; try { if (navigator.clipboard) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } setSuccess('Пример формата скопирован'); setTimeout(() => setSuccess(''), 2000); } catch (e) { setError('Не удалось скопировать пример'); setTimeout(() => setError(''), 2000); } }; // Статистика const stats = useMemo(() => { const valid = urls.filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)).length; const invalid = urls.length - valid; return { total: urls.length, valid, invalid }; }, [urls]); // Фильтрация и сортировка const filteredAndSortedUrls = useMemo(() => { let filtered = urls.filter(u => { if (!u) return false; const matchesSearch = !searchTerm || u.url.toLowerCase().includes(searchTerm.toLowerCase()) || String(u.community).toLowerCase().includes(searchTerm.toLowerCase()); if (!matchesSearch) return false; if (filterValid === 'valid') { return isValidHttpUrl(u.url) && isValidCommunity(u.community); } else if (filterValid === 'invalid') { return !isValidHttpUrl(u.url) || !isValidCommunity(u.community); } return true; }); if (sortField) { filtered = [...filtered].sort((a, b) => { const aVal = String(a[sortField] || '').toLowerCase(); const bVal = String(b[sortField] || '').toLowerCase(); const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; return sortDir === 'asc' ? comparison : -comparison; }); } return filtered; }, [urls, searchTerm, filterValid, sortField, sortDir]); const toggleSort = (field) => { if (sortField === field) { setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortDir('asc'); } }; const toggleSelectRow = (originalIndex) => { setSelectedRows(prev => { const next = new Set(prev); if (next.has(originalIndex)) next.delete(originalIndex); else next.add(originalIndex); return next; }); }; const toggleSelectAll = () => { const allOriginalIndices = filteredAndSortedUrls.map(url => urls.findIndex(u => u === url)); const allSelected = allOriginalIndices.every(idx => selectedRows.has(idx)); if (allSelected) { setSelectedRows(new Set()); } else { setSelectedRows(new Set(allOriginalIndices)); } }; const deleteSelected = () => { setUrls(prev => prev.filter((_, i) => !selectedRows.has(i))); setSelectedRows(new Set()); }; const exportUrls = () => { const text = urls .filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)) .map(u => `${u.url.trim()} ${String(u.community).trim()}`) .join('\n'); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `auto-urls-${new Date().toISOString().split('T')[0]}.txt`; a.click(); URL.revokeObjectURL(url); }; // Горячие клавиши useEffect(() => { const handleKeyDown = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); // Разрешить сохранение: либо пустой список, либо есть валидные строки const canSave = urls.length === 0 || hasAtLeastOneValidRow; if (canSave && !saving && !processing) { saveUrls(); } } if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); searchInputRef.current?.focus(); } if (e.key === 'Escape') { setSearchTerm(''); setFilterValid('all'); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [urls.length, hasAtLeastOneValidRow, saving, processing, saveUrls]); return (
{stats.total > 0 && ( )}
)} /> {/* Уведомления */} {error && ( setError('')} onRetry={fetchUrls} /> )} {success && (
{success}
)} {/* Статистика */} {!loading && urls.length > 0 && (
{stats.total}
Всего URL
{stats.valid}
Валидных
{stats.invalid}
Невалидных
{filteredAndSortedUrls.length}
Отображается
)}

Список URL-адресов

setSearchTerm(e.target.value)} /> {searchTerm && ( )}
{loading ? ( ) : urls.length === 0 ? ( Добавить первый URL )} secondaryAction={( )} /> ) : filteredAndSortedUrls.length === 0 ? ( { setSearchTerm(''); setFilterValid('all'); }} > Сбросить фильтры )} /> ) : ( <> {selectedRows.size > 0 && (
Выбрано записей: {selectedRows.size}
)}
{filteredAndSortedUrls.map((url, displayIndex) => { const originalIndex = urls.findIndex(u => u === url); const v = getRowValidity(url); const isValid = v.url && v.community; const isInvalid = touched[originalIndex] && (!v.url || !v.community); const isSelected = selectedRows.has(originalIndex); return ( ); })}
0 && filteredAndSortedUrls.every(url => { const idx = urls.findIndex(u => u === url); return selectedRows.has(idx); })} onChange={toggleSelectAll} title="Выбрать все" /> toggleSort('url')} style={{ minWidth: 300 }} >
URL {sortField === 'url' && ( sortDir === 'asc' ? : )}
toggleSort('community')} style={{ minWidth: 150 }} >
Community {sortField === 'community' && ( sortDir === 'asc' ? : )}
Статус Действия
toggleSelectRow(originalIndex)} />
{v.url ? ( ) : touched[originalIndex] ? ( ) : null} updateUrl(originalIndex, 'url', e.target.value)} disabled={saving || processing} />
{isInvalid && !v.url && (
Укажите корректный http/https URL
)}
{v.community ? ( ) : touched[originalIndex] ? ( ) : null} updateUrl(originalIndex, 'community', e.target.value)} disabled={saving || processing} />
{isInvalid && !v.community && (
Только цифры, например 555
)}
{isValid ? ( Валидно ) : isInvalid ? ( Ошибка ) : ( Не заполнено )}
)}
{urls.length > 0 && ( <> Всего: {stats.total} Валидных: {stats.valid} {stats.invalid > 0 && ( Невалидных: {stats.invalid} )} )} {lastSaved && ( )}
Введите http/https URL и числовой community. Невалидные поля подсвечиваются.

Информация

Как это работает

  • Добавьте URL-адреса, которые содержат списки IP-адресов или доменных имён
  • Укажите community для каждого URL (только цифры)
  • Нажмите «Загрузить списки» для обработки всех URL
  • IP-диапазоны попадут в bgp_data/ips.txt, домены — в bgp_data/domains_community.txt

Формат файла

Пример

                    {`https://test.com/ips.txt 555\nhttps://example.com/blacklist.txt 666`}
                  
Каждая строка: URL и community через пробел
{/* Модальное окно импорта */} { const [u, c] = String(line).split(/\s+/); return { url: u || '', community: c || '' }; }} validateItem={(obj) => Boolean(obj?.url) && Boolean(obj?.community)} onConfirm={(items) => { setUrls(prev => [...prev, ...items]); setImportOpen(false); }} onClose={() => setImportOpen(false)} /> ); } export default AutoUrlManager;