Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
412 lines
16 KiB
React
412 lines
16 KiB
React
import { useState, useEffect } from 'react';
|
|
import api from './lib/api.js';
|
|
import {
|
|
IconPlus,
|
|
IconTrash,
|
|
IconDownload,
|
|
IconAlertCircle,
|
|
IconCheck,
|
|
IconLoader,
|
|
IconUpload,
|
|
IconDeviceFloppy,
|
|
IconInfoCircle,
|
|
IconCircleCheck,
|
|
IconCopy
|
|
} from '@tabler/icons-react';
|
|
|
|
function AutoUrlManager() {
|
|
const [urls, setUrls] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [processing, setProcessing] = useState(false);
|
|
const [message, setMessage] = useState('');
|
|
const [messageType, setMessageType] = useState('');
|
|
const [touched, setTouched] = useState({});
|
|
|
|
useEffect(() => {
|
|
fetchUrls();
|
|
}, []);
|
|
|
|
const fetchUrls = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await api.get('/auto-urls');
|
|
setUrls(response.data);
|
|
} catch (error) {
|
|
console.error('Error fetching URLs:', error);
|
|
setMessage('Ошибка при загрузке URL-адресов');
|
|
setMessageType('error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const addUrl = () => {
|
|
setUrls([...urls, { url: '', 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);
|
|
setMessage('');
|
|
|
|
// 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() }));
|
|
if (validUrls.length === 0) {
|
|
setMessage('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)');
|
|
setMessageType('error');
|
|
return;
|
|
}
|
|
|
|
await api.post('/auto-urls', { urls: validUrls });
|
|
setUrls(validUrls);
|
|
setMessage('URL-адреса сохранены успешно');
|
|
setMessageType('success');
|
|
} catch (error) {
|
|
console.error('Error saving URLs:', error);
|
|
setMessage('Ошибка при сохранении URL-адресов');
|
|
setMessageType('error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const processUrls = async () => {
|
|
try {
|
|
setProcessing(true);
|
|
setMessage('');
|
|
|
|
const response = await api.post('/auto-urls/process');
|
|
setMessage(response.data.message);
|
|
setMessageType('success');
|
|
} catch (error) {
|
|
console.error('Error processing URLs:', error);
|
|
setMessage(error.response?.data || 'Ошибка при обработке URL-адресов');
|
|
setMessageType('error');
|
|
} 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);
|
|
}
|
|
setMessage('Пример формата скопирован');
|
|
setMessageType('success');
|
|
setTimeout(() => setMessage(''), 2000);
|
|
} catch (e) {
|
|
setMessage('Не удалось скопировать пример');
|
|
setMessageType('error');
|
|
setTimeout(() => setMessage(''), 2000);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '200px' }}>
|
|
<div className="spinner-border" role="status" aria-label="Загрузка"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="page-header d-print-none mb-4">
|
|
<div className="row align-items-center">
|
|
<div className="col">
|
|
<h2 className="page-title">Автоматические URL</h2>
|
|
<div className="page-pretitle">Управление автоматическими URL-адресами для загрузки списков IP и доменов</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className={`alert alert-${messageType === 'error' ? 'danger' : 'success'} alert-dismissible`} role="alert">
|
|
<div className="d-flex">
|
|
{messageType === 'error' ? <IconAlertCircle className="me-2" /> : <IconCheck className="me-2" />}
|
|
{message}
|
|
</div>
|
|
<button type="button" className="btn-close" onClick={() => setMessage('')}></button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="card-title">URL-адреса для автоматической загрузки</h3>
|
|
<div className="card-actions">
|
|
<div className="btn-list">
|
|
<button
|
|
className="btn btn-outline-secondary btn-sm"
|
|
onClick={() => {
|
|
const text = window.prompt('Вставьте строки вида: URL ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
|
if (!text) return;
|
|
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
const parsed = lines.map(l => {
|
|
const [u, c] = l.split(/\s+/);
|
|
return { url: u || '', community: c || '' };
|
|
});
|
|
setUrls(prev => [...prev, ...parsed]);
|
|
}}
|
|
disabled={saving || processing}
|
|
title="Массовое добавление из буфера"
|
|
>
|
|
<IconUpload className="me-1" />
|
|
Импорт
|
|
</button>
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
onClick={addUrl}
|
|
disabled={saving || processing}
|
|
>
|
|
<IconPlus className="me-1" />
|
|
Добавить URL
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
{urls.length === 0 ? (
|
|
<div className="text-center text-muted py-4">
|
|
<IconAlertCircle size={48} className="mb-3" />
|
|
<p>Нет добавленных URL-адресов</p>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={addUrl}
|
|
disabled={saving || processing}
|
|
>
|
|
<IconPlus className="me-1" />
|
|
Добавить первый URL
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="table-responsive">
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>URL</th>
|
|
<th>Community</th>
|
|
<th className="text-end" style={{ width: 100 }}>Действия</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{urls.map((url, index) => (
|
|
<tr key={index}>
|
|
<td>
|
|
{(() => {
|
|
const v = getRowValidity(url);
|
|
const invalid = touched[index] && !v.url;
|
|
return (
|
|
<>
|
|
<input
|
|
type="text"
|
|
className={`form-control${invalid ? ' is-invalid' : ''}`}
|
|
placeholder="https://example.com/ips.txt"
|
|
value={url.url}
|
|
onChange={(e) => updateUrl(index, 'url', e.target.value)}
|
|
disabled={saving || processing}
|
|
/>
|
|
{invalid && (
|
|
<div className="invalid-feedback">Укажите корректный http/https URL</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</td>
|
|
<td>
|
|
{(() => {
|
|
const v = getRowValidity(url);
|
|
const invalid = touched[index] && !v.community;
|
|
return (
|
|
<>
|
|
<input
|
|
type="text"
|
|
className={`form-control${invalid ? ' is-invalid' : ''}`}
|
|
placeholder="555"
|
|
value={url.community}
|
|
onChange={(e) => updateUrl(index, 'community', e.target.value)}
|
|
disabled={saving || processing}
|
|
/>
|
|
{invalid && (
|
|
<div className="invalid-feedback">Только цифры, например 555</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</td>
|
|
<td className="text-end">
|
|
<button
|
|
className="btn btn-outline-danger btn-icon btn-sm"
|
|
onClick={() => removeUrl(index)}
|
|
disabled={saving || processing}
|
|
>
|
|
<IconTrash size={16} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="card-footer">
|
|
<div className="d-flex justify-content-between align-items-center">
|
|
<div className="text-muted">
|
|
{urls.length > 0 && (
|
|
<span className="badge bg-blue-lt text-blue">Всего URL-адресов: {urls.length}</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<button
|
|
className="btn btn-outline-secondary me-2"
|
|
onClick={() => setUrls(urls.filter(u => u != null && (u.url || u.community)))}
|
|
disabled={saving || processing || urls.length === 0}
|
|
title="Удалить пустые строки"
|
|
>
|
|
Очистить пустые
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-primary me-2"
|
|
onClick={saveUrls}
|
|
disabled={saving || processing || !hasAtLeastOneValidRow}
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
|
Сохранение...
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconDeviceFloppy className="me-1" />
|
|
Сохранить
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={processUrls}
|
|
disabled={saving || processing || !hasAtLeastOneValidRow}
|
|
>
|
|
{processing ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
|
Обработка...
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconDownload className="me-1" />
|
|
Загрузить списки
|
|
</>
|
|
)}
|
|
</button>
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AutoUrlManager; |