Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
824 lines
33 KiB
React
824 lines
33 KiB
React
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 (
|
|
<div>
|
|
<PageHeader
|
|
title="Автоматические URL"
|
|
pretitle="Управление автоматическими URL-адресами для загрузки списков IP и доменов"
|
|
actions={(
|
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
|
<div className="btn-list">
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
type="button"
|
|
onClick={() => setImportOpen(true)}
|
|
disabled={saving || processing}
|
|
title="Импорт из текста или файла"
|
|
>
|
|
<IconUpload className="me-1" /> Импорт
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
type="button"
|
|
onClick={addUrl}
|
|
disabled={saving || processing}
|
|
title="Добавить строку"
|
|
>
|
|
<IconPlus className="me-1" /> Добавить URL
|
|
</button>
|
|
</div>
|
|
<div className="ms-auto btn-list">
|
|
{stats.total > 0 && (
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
onClick={exportUrls}
|
|
disabled={saving || processing || stats.valid === 0}
|
|
title="Экспорт валидных URL"
|
|
>
|
|
<IconFileExport className="me-1" /> Экспорт
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
onClick={clearEmptyRows}
|
|
disabled={saving || processing || urls.length === 0}
|
|
title="Удалить пустые строки"
|
|
>
|
|
Очистить пустые
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
type="button"
|
|
onClick={saveUrls}
|
|
disabled={saving || processing || (urls.length > 0 && !hasAtLeastOneValidRow)}
|
|
title="Сохранить список URL (Ctrl+S)"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
|
Сохранение...
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconDeviceFloppy className="me-1" /> Сохранить
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
type="button"
|
|
onClick={processUrls}
|
|
disabled={saving || processing || !hasAtLeastOneValidRow}
|
|
title="Загрузить и обработать все URL"
|
|
>
|
|
{processing ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
|
Обработка...
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconDownload className="me-1" /> Загрузить списки
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
{/* Уведомления */}
|
|
{error && (
|
|
<ErrorAlert
|
|
message={error}
|
|
onClose={() => setError('')}
|
|
onRetry={fetchUrls}
|
|
/>
|
|
)}
|
|
{success && (
|
|
<div className="alert alert-success alert-dismissible" role="alert">
|
|
<div className="d-flex">
|
|
<IconCheck className="me-2" />
|
|
{success}
|
|
</div>
|
|
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Статистика */}
|
|
{!loading && urls.length > 0 && (
|
|
<div className="row g-3 mb-4">
|
|
<div className="col-md-3">
|
|
<div className="card h-100">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className="avatar avatar-lg me-3 bg-blue-lt text-blue border-0">
|
|
<IconWorld size={32} />
|
|
</span>
|
|
<div>
|
|
<div className="h3 mb-0 fw-bold">{stats.total}</div>
|
|
<div className="text-muted small">Всего URL</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card h-100">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className="avatar avatar-lg me-3 bg-green-lt text-green border-0">
|
|
<IconCheck size={32} />
|
|
</span>
|
|
<div>
|
|
<div className="h3 mb-0 fw-bold">{stats.valid}</div>
|
|
<div className="text-muted small">Валидных</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card h-100">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className="avatar avatar-lg me-3 bg-red-lt text-red border-0">
|
|
<IconAlertCircle size={32} />
|
|
</span>
|
|
<div>
|
|
<div className="h3 mb-0 fw-bold">{stats.invalid}</div>
|
|
<div className="text-muted small">Невалидных</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card h-100">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className="avatar avatar-lg me-3 bg-azure-lt text-azure border-0">
|
|
<IconLink size={32} />
|
|
</span>
|
|
<div>
|
|
<div className="h3 mb-0 fw-bold">{filteredAndSortedUrls.length}</div>
|
|
<div className="text-muted small">Отображается</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<div className="row align-items-center g-2">
|
|
<div className="col">
|
|
<h3 className="card-title mb-0">
|
|
<IconLink className="me-2" />
|
|
Список URL-адресов
|
|
</h3>
|
|
</div>
|
|
<div className="col-auto">
|
|
<div className="input-icon" style={{ maxWidth: 320 }}>
|
|
<span className="input-icon-addon">
|
|
<IconSearch size={16} />
|
|
</span>
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
className="form-control"
|
|
placeholder="Поиск (Ctrl+F)..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
{searchTerm && (
|
|
<button
|
|
className="btn btn-icon btn-sm btn-outline-secondary position-absolute top-50 end-0 translate-middle-y me-1"
|
|
type="button"
|
|
onClick={() => setSearchTerm('')}
|
|
title="Очистить поиск"
|
|
>
|
|
<IconX size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="col-auto">
|
|
<div className="btn-group btn-group-sm">
|
|
<button
|
|
className={`btn ${filterValid === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
|
|
onClick={() => setFilterValid('all')}
|
|
title="Все записи"
|
|
>
|
|
Все
|
|
</button>
|
|
<button
|
|
className={`btn ${filterValid === 'valid' ? 'btn-success' : 'btn-outline-success'}`}
|
|
onClick={() => setFilterValid('valid')}
|
|
title="Только валидные"
|
|
>
|
|
<IconCheck size={14} className="me-1" />
|
|
Валидные
|
|
</button>
|
|
<button
|
|
className={`btn ${filterValid === 'invalid' ? 'btn-danger' : 'btn-outline-danger'}`}
|
|
onClick={() => setFilterValid('invalid')}
|
|
title="Только невалидные"
|
|
>
|
|
<IconAlertCircle size={14} className="me-1" />
|
|
Невалидные
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
{loading ? (
|
|
<TableSkeleton rows={6} cols={4} />
|
|
) : urls.length === 0 ? (
|
|
<EmptyState
|
|
icon={IconAlertCircle}
|
|
title="Нет добавленных URL-адресов"
|
|
description="Добавьте хотя бы одну строку с корректным URL и числовым community."
|
|
size="default"
|
|
action={(
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={addUrl}
|
|
disabled={saving || processing}
|
|
>
|
|
<IconPlus className="me-1" />
|
|
Добавить первый URL
|
|
</button>
|
|
)}
|
|
secondaryAction={(
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={() => setImportOpen(true)}
|
|
disabled={saving || processing}
|
|
>
|
|
<IconUpload className="me-1" />
|
|
Импорт
|
|
</button>
|
|
)}
|
|
/>
|
|
) : filteredAndSortedUrls.length === 0 ? (
|
|
<EmptyState
|
|
icon={IconSearch}
|
|
title="Ничего не найдено"
|
|
description={searchTerm ? `По запросу "${searchTerm}" ничего не найдено` : 'Нет записей, соответствующих выбранному фильтру'}
|
|
size="default"
|
|
action={(
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
onClick={() => {
|
|
setSearchTerm('');
|
|
setFilterValid('all');
|
|
}}
|
|
>
|
|
<IconX className="me-1" />
|
|
Сбросить фильтры
|
|
</button>
|
|
)}
|
|
/>
|
|
) : (
|
|
<>
|
|
{selectedRows.size > 0 && (
|
|
<div className="alert alert-info d-flex justify-content-between align-items-center mb-3">
|
|
<span>Выбрано записей: <strong>{selectedRows.size}</strong></span>
|
|
<div className="btn-list">
|
|
<button
|
|
className="btn btn-sm btn-outline-danger"
|
|
onClick={deleteSelected}
|
|
>
|
|
<IconTrash size={14} className="me-1" />
|
|
Удалить выбранные
|
|
</button>
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => setSelectedRows(new Set())}
|
|
>
|
|
<IconX size={14} className="me-1" />
|
|
Отменить выбор
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="table-responsive">
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 40 }}>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={filteredAndSortedUrls.length > 0 && filteredAndSortedUrls.every(url => {
|
|
const idx = urls.findIndex(u => u === url);
|
|
return selectedRows.has(idx);
|
|
})}
|
|
onChange={toggleSelectAll}
|
|
title="Выбрать все"
|
|
/>
|
|
</th>
|
|
<th
|
|
className="cursor-pointer"
|
|
onClick={() => toggleSort('url')}
|
|
style={{ minWidth: 300 }}
|
|
>
|
|
<div className="d-flex align-items-center">
|
|
<IconLink size={16} className="me-1" />
|
|
URL
|
|
{sortField === 'url' && (
|
|
sortDir === 'asc' ? <IconSortAscending size={16} className="ms-1" /> : <IconSortDescending size={16} className="ms-1" />
|
|
)}
|
|
</div>
|
|
</th>
|
|
<th
|
|
className="cursor-pointer"
|
|
onClick={() => toggleSort('community')}
|
|
style={{ minWidth: 150 }}
|
|
>
|
|
<div className="d-flex align-items-center">
|
|
<IconHash size={16} className="me-1" />
|
|
Community
|
|
{sortField === 'community' && (
|
|
sortDir === 'asc' ? <IconSortAscending size={16} className="ms-1" /> : <IconSortDescending size={16} className="ms-1" />
|
|
)}
|
|
</div>
|
|
</th>
|
|
<th style={{ width: 100 }}>Статус</th>
|
|
<th className="text-end" style={{ width: 100 }}>Действия</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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 (
|
|
<tr
|
|
key={originalIndex}
|
|
className={isSelected ? 'table-active' : ''}
|
|
>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={isSelected}
|
|
onChange={() => toggleSelectRow(originalIndex)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<div className="input-group input-group-sm">
|
|
<span className="input-group-text bg-transparent border-0 p-0 me-1">
|
|
{v.url ? (
|
|
<IconCheck size={16} className="text-success" />
|
|
) : touched[originalIndex] ? (
|
|
<IconAlertCircle size={16} className="text-danger" />
|
|
) : null}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
className={`form-control${isInvalid && !v.url ? ' is-invalid' : ''}`}
|
|
placeholder="https://example.com/ips.txt"
|
|
value={url.url}
|
|
onChange={(e) => updateUrl(originalIndex, 'url', e.target.value)}
|
|
disabled={saving || processing}
|
|
/>
|
|
</div>
|
|
{isInvalid && !v.url && (
|
|
<div className="invalid-feedback d-block">Укажите корректный http/https URL</div>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<div className="input-group input-group-sm">
|
|
<span className="input-group-text bg-transparent border-0 p-0 me-1">
|
|
{v.community ? (
|
|
<IconCheck size={16} className="text-success" />
|
|
) : touched[originalIndex] ? (
|
|
<IconAlertCircle size={16} className="text-danger" />
|
|
) : null}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
className={`form-control${isInvalid && !v.community ? ' is-invalid' : ''}`}
|
|
placeholder="555"
|
|
value={url.community}
|
|
onChange={(e) => updateUrl(originalIndex, 'community', e.target.value)}
|
|
disabled={saving || processing}
|
|
/>
|
|
</div>
|
|
{isInvalid && !v.community && (
|
|
<div className="invalid-feedback d-block">Только цифры, например 555</div>
|
|
)}
|
|
</td>
|
|
<td>
|
|
{isValid ? (
|
|
<span className="badge bg-green-lt text-green">
|
|
<IconCheck size={12} className="me-1" />
|
|
Валидно
|
|
</span>
|
|
) : isInvalid ? (
|
|
<span className="badge bg-red-lt text-red">
|
|
<IconAlertCircle size={12} className="me-1" />
|
|
Ошибка
|
|
</span>
|
|
) : (
|
|
<span className="badge bg-secondary-lt text-secondary">Не заполнено</span>
|
|
)}
|
|
</td>
|
|
<td className="text-end">
|
|
<button
|
|
className="btn btn-outline-danger btn-icon btn-sm"
|
|
onClick={() => removeUrl(originalIndex)}
|
|
disabled={saving || processing}
|
|
title="Удалить"
|
|
>
|
|
<IconTrash size={16} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="card-footer">
|
|
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<div className="d-flex align-items-center gap-2">
|
|
{urls.length > 0 && (
|
|
<>
|
|
<span className="badge bg-blue-lt text-blue">Всего: {stats.total}</span>
|
|
<span className="badge bg-green-lt text-green">Валидных: {stats.valid}</span>
|
|
{stats.invalid > 0 && (
|
|
<span className="badge bg-red-lt text-red">Невалидных: {stats.invalid}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
{lastSaved && (
|
|
<LastSaved timestamp={lastSaved} variant="compact" />
|
|
)}
|
|
</div>
|
|
<div className="text-muted small">
|
|
Введите http/https URL и числовой community. Невалидные поля подсвечиваются.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card mt-4">
|
|
<div className="card-header">
|
|
<h3 className="card-title"><IconInfoCircle className="me-2" />Информация</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="row g-4">
|
|
<div className="col-md-6">
|
|
<h4 className="mb-3">Как это работает</h4>
|
|
<ul className="list-unstyled m-0">
|
|
<li className="d-flex align-items-start mb-2">
|
|
<span className="text-blue me-2"><IconCircleCheck size={18} /></span>
|
|
<span>Добавьте URL-адреса, которые содержат списки IP-адресов или доменных имён</span>
|
|
</li>
|
|
<li className="d-flex align-items-start mb-2">
|
|
<span className="text-blue me-2"><IconCircleCheck size={18} /></span>
|
|
<span>Укажите community для каждого URL (только цифры)</span>
|
|
</li>
|
|
<li className="d-flex align-items-start mb-2">
|
|
<span className="text-blue me-2"><IconCircleCheck size={18} /></span>
|
|
<span>Нажмите «Загрузить списки» для обработки всех URL</span>
|
|
</li>
|
|
<li className="d-flex align-items-start">
|
|
<span className="text-blue me-2"><IconCircleCheck size={18} /></span>
|
|
<span>IP-диапазоны попадут в <code>bgp_data/ips.txt</code>, домены — в <code>bgp_data/domains_community.txt</code></span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<h4 className="mb-3">Формат файла</h4>
|
|
<div className="card card-sm">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Пример</h3>
|
|
<div className="card-actions">
|
|
<button className="btn btn-outline-secondary btn-sm" onClick={copyExample}>
|
|
<IconCopy className="me-1" />
|
|
Копировать
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
<pre
|
|
className="m-0 p-2 bg-dark text-light rounded"
|
|
style={{
|
|
fontFamily:
|
|
"ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
|
|
fontSize: '0.875rem',
|
|
overflow: 'auto'
|
|
}}
|
|
>
|
|
<code>{`https://test.com/ips.txt 555\nhttps://example.com/blacklist.txt 666`}</code>
|
|
</pre>
|
|
<div className="text-muted small mt-2">Каждая строка: URL и community через пробел</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Модальное окно импорта */}
|
|
<ImportModal
|
|
show={importOpen}
|
|
title="Импорт URL + community"
|
|
description="Формат строк: URL ПРОБЕЛ COMMUNITY. Например: https://example.com/ips.txt 555"
|
|
sampleHeader={['url', 'community']}
|
|
placeholder={'https://example.com/ips.txt 555\nhttps://test.com/domains.txt 666'}
|
|
parseLine={(line) => {
|
|
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)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AutoUrlManager; |