feat: Add filters API endpoints for retrieving, updating, generating, and exporting MikroTik configurations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 7m37s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 7m37s
This commit is contained in:
+200
-3
@@ -195,18 +195,89 @@ app.post('/api/servers', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Filters Routes (JSON format) ---
|
||||
|
||||
// Get filters from S3
|
||||
app.get('/api/filters', async (req, res) => {
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'filters.json',
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await s3.getObject(params).promise();
|
||||
const fileContent = data.Body.toString('utf-8');
|
||||
let filters = [];
|
||||
|
||||
try {
|
||||
filters = JSON.parse(fileContent);
|
||||
// Ensure it's an array
|
||||
if (!Array.isArray(filters)) {
|
||||
filters = [];
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing filters.json:', parseError);
|
||||
filters = [];
|
||||
}
|
||||
|
||||
res.json(filters);
|
||||
} catch (error) {
|
||||
if (error.code === 'NoSuchKey') {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading from S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update filters in S3
|
||||
app.post('/api/filters', async (req, res) => {
|
||||
const { domains: filters } = req.body; // Keep name 'domains' for consistency
|
||||
|
||||
// Validate filters structure
|
||||
if (!Array.isArray(filters)) {
|
||||
return res.status(400).send('Filters must be an array');
|
||||
}
|
||||
|
||||
// Validate each filter has required fields
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
const filter = filters[i];
|
||||
if (!filter.community || !filter.gateway) {
|
||||
return res.status(400).send(`Filter at index ${i} is missing required fields`);
|
||||
}
|
||||
}
|
||||
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'filters.json',
|
||||
Body: JSON.stringify(filters, null, 2), // Pretty print JSON
|
||||
ContentType: 'application/json',
|
||||
};
|
||||
|
||||
try {
|
||||
await s3.putObject(params).promise();
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing to S3');
|
||||
}
|
||||
});
|
||||
|
||||
// Новый эндпоинт для получения дат последнего изменения файлов S3
|
||||
app.get('/api/s3/last-modified', async (req, res) => {
|
||||
try {
|
||||
const [domainsHead, asnsHead, serversHead] = await Promise.all([
|
||||
const [domainsHead, asnsHead, serversHead, filtersHead] = await Promise.all([
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'domains.txt' }).promise(),
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'asns.txt' }).promise(),
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise()
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise(),
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'filters.json' }).promise()
|
||||
]);
|
||||
res.json({
|
||||
domainsLastModified: domainsHead.LastModified ? domainsHead.LastModified.toISOString() : null,
|
||||
asnsLastModified: asnsHead.LastModified ? asnsHead.LastModified.toISOString() : null,
|
||||
serversLastModified: serversHead.LastModified ? serversHead.LastModified.toISOString() : null
|
||||
serversLastModified: serversHead.LastModified ? serversHead.LastModified.toISOString() : null,
|
||||
filtersLastModified: filtersHead.LastModified ? filtersHead.LastModified.toISOString() : null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -214,6 +285,132 @@ app.get('/api/s3/last-modified', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate MikroTik configuration from filters
|
||||
app.get('/api/filters/generate-config', async (req, res) => {
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'filters.json',
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await s3.getObject(params).promise();
|
||||
const fileContent = data.Body.toString('utf-8');
|
||||
let filters = [];
|
||||
|
||||
try {
|
||||
filters = JSON.parse(fileContent);
|
||||
if (!Array.isArray(filters)) {
|
||||
filters = [];
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing filters.json:', parseError);
|
||||
filters = [];
|
||||
}
|
||||
|
||||
if (filters.length === 0) {
|
||||
return res.json({ config: '// Нет фильтров для генерации конфигурации' });
|
||||
}
|
||||
|
||||
// Group filters by gateway
|
||||
const gatewayGroups = {};
|
||||
filters.forEach(filter => {
|
||||
if (!gatewayGroups[filter.gateway]) {
|
||||
gatewayGroups[filter.gateway] = [];
|
||||
}
|
||||
gatewayGroups[filter.gateway].push(filter.community);
|
||||
});
|
||||
|
||||
let config = '// Конфигурация фильтра frouting для MikroTik 7.14+\n';
|
||||
config += '// Сгенерировано автоматически\n\n';
|
||||
|
||||
Object.entries(gatewayGroups).forEach(([gateway, communities]) => {
|
||||
config += `if (\n`;
|
||||
communities.forEach((community, index) => {
|
||||
config += `(bgp-communities includes ${community})`;
|
||||
if (index < communities.length - 1) {
|
||||
config += ` \nor `;
|
||||
}
|
||||
});
|
||||
config += `\n)\n{\n set gw ${gateway}; accept;\n}\nelse\n{\n reject;\n}\n\n`;
|
||||
});
|
||||
|
||||
res.json({ config });
|
||||
} catch (error) {
|
||||
if (error.code === 'NoSuchKey') {
|
||||
res.json({ config: '// Файл filters.json не найден' });
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error generating configuration');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export MikroTik configuration to S3
|
||||
app.post('/api/filters/export-config', async (req, res) => {
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'filters.json',
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await s3.getObject(params).promise();
|
||||
const fileContent = data.Body.toString('utf-8');
|
||||
let filters = [];
|
||||
|
||||
try {
|
||||
filters = JSON.parse(fileContent);
|
||||
if (!Array.isArray(filters)) {
|
||||
filters = [];
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing filters.json:', parseError);
|
||||
filters = [];
|
||||
}
|
||||
|
||||
if (filters.length === 0) {
|
||||
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
|
||||
}
|
||||
|
||||
// Group filters by gateway
|
||||
const gatewayGroups = {};
|
||||
filters.forEach(filter => {
|
||||
if (!gatewayGroups[filter.gateway]) {
|
||||
gatewayGroups[filter.gateway] = [];
|
||||
}
|
||||
gatewayGroups[filter.gateway].push(filter.community);
|
||||
});
|
||||
|
||||
let config = '// Конфигурация фильтра frouting для MikroTik 7.14+\n';
|
||||
config += '// Сгенерировано автоматически\n';
|
||||
config += `// Дата: ${new Date().toISOString()}\n\n`;
|
||||
|
||||
Object.entries(gatewayGroups).forEach(([gateway, communities]) => {
|
||||
config += `if (\n`;
|
||||
communities.forEach((community, index) => {
|
||||
config += `(bgp-communities includes ${community})`;
|
||||
if (index < communities.length - 1) {
|
||||
config += ` \nor `;
|
||||
}
|
||||
});
|
||||
config += `\n)\n{\n set gw ${gateway}; accept;\n}\nelse\n{\n reject;\n}\n\n`;
|
||||
});
|
||||
|
||||
// Save configuration to S3
|
||||
const exportParams = {
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'mikrotik-frouting-config.txt',
|
||||
Body: config,
|
||||
ContentType: 'text/plain',
|
||||
};
|
||||
|
||||
await s3.putObject(exportParams).promise();
|
||||
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error exporting configuration');
|
||||
}
|
||||
});
|
||||
|
||||
// The "catchall" handler: for any request that doesn't
|
||||
// match one above, send back React's index.html file.
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
@@ -16,10 +16,12 @@ import {
|
||||
IconDatabase,
|
||||
IconAlertTriangle,
|
||||
IconAlertCircle,
|
||||
IconServer
|
||||
IconServer,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react';
|
||||
import DataManager from './DataManager';
|
||||
import ServerManager from './ServerManager';
|
||||
import FilterManager from './FilterManager';
|
||||
import './App.css';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ function MainLayout() {
|
||||
{ id: 'domains', title: 'Домены', icon: IconWorld, path: '/domains' },
|
||||
{ id: 'asns', title: 'AS', icon: IconNetwork, path: '/asns' },
|
||||
{ id: 'servers', title: 'Серверы', icon: IconServer, path: '/servers' },
|
||||
{ id: 'filters', title: 'Фильтры', icon: IconFilter, path: '/filters' },
|
||||
{ id: 'files', title: 'Файлы', icon: IconFileText, path: '/files' },
|
||||
{ id: 'cloud', title: 'Облако', icon: IconCloud, path: '/cloud' },
|
||||
{ id: 'security', title: 'Безопасность', icon: IconShield, path: '/security' },
|
||||
@@ -70,7 +73,7 @@ function MainLayout() {
|
||||
<span className="fw-bold">S3 Lists Manager</span>
|
||||
</Link>
|
||||
<nav className="nav nav-tabs ms-4">
|
||||
{navItems.slice(0, 3).map(item => (
|
||||
{navItems.slice(0, 4).map(item => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
@@ -100,6 +103,7 @@ function MainLayout() {
|
||||
/>
|
||||
} />
|
||||
<Route path="/servers" element={<ServerManager />} />
|
||||
<Route path="/filters" element={<FilterManager />} />
|
||||
<Route path="/" element={<Navigate to="/domains" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconAlertTriangle,
|
||||
IconFilter,
|
||||
IconChevronDown,
|
||||
IconLink,
|
||||
IconCopy,
|
||||
IconEye
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function FilterManager() {
|
||||
const [filters, setFilters] = useState([]);
|
||||
const [newFilter, setNewFilter] = useState({
|
||||
community: '',
|
||||
gateway: '',
|
||||
description: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingFilter, setEditingFilter] = useState(null);
|
||||
const [editingValues, setEditingValues] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [filterToDelete, setFilterToDelete] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortField, setSortField] = useState('community');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [filterGateway, setFilterGateway] = useState('');
|
||||
const editInputRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalFilter, setEditModalFilter] = useState(null);
|
||||
|
||||
// Состояние для предварительного просмотра конфигурации
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
const [generatedConfig, setGeneratedConfig] = useState('');
|
||||
|
||||
// Состояние для модального окна добавления фильтра
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilters();
|
||||
}, []);
|
||||
|
||||
const fetchFilters = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/filters`);
|
||||
setFilters(response.data);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error('Error fetching filters:', error);
|
||||
setError('Не удалось загрузить фильтры. Проверьте, запущен ли бэкенд.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFilter = () => {
|
||||
if (!newFilter.community.trim() || !newFilter.gateway.trim()) {
|
||||
setError('Поля Community и Gateway должны быть заполнены.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setFilters([...filters, { ...newFilter }]);
|
||||
setNewFilter({ community: '', gateway: '', description: '' });
|
||||
};
|
||||
|
||||
// Функции для модального окна добавления фильтра
|
||||
const handleOpenAddFilterModal = () => {
|
||||
setAddFilterModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseAddFilterModal = () => {
|
||||
setAddFilterModalOpen(false);
|
||||
setNewFilter({ community: '', gateway: '', description: '' });
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleAddFilterFromModal = () => {
|
||||
if (!newFilter.community.trim() || !newFilter.gateway.trim()) {
|
||||
setError('Поля Community и Gateway должны быть заполнены.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setFilters([...filters, { ...newFilter }]);
|
||||
setNewFilter({ community: '', gateway: '', description: '' });
|
||||
setAddFilterModalOpen(false);
|
||||
setSuccess('Фильтр успешно добавлен!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
};
|
||||
|
||||
const handleEdit = (filter) => {
|
||||
setEditModalFilter({ ...filter });
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditModalChange = (updatedFilter) => {
|
||||
setEditModalFilter(updatedFilter);
|
||||
};
|
||||
|
||||
const handleEditModalSave = (updatedFilter) => {
|
||||
setFilters(filters.map(f => f.community === updatedFilter.community ? { ...updatedFilter } : f));
|
||||
setEditModalOpen(false);
|
||||
setEditModalFilter(null);
|
||||
};
|
||||
|
||||
const handleEditModalClose = () => {
|
||||
setEditModalOpen(false);
|
||||
setEditModalFilter(null);
|
||||
};
|
||||
|
||||
const handleDelete = (filter) => {
|
||||
setFilterToDelete(filter);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (filterToDelete) {
|
||||
setFilters(filters.filter(f => f.community !== filterToDelete.community));
|
||||
setShowDeleteModal(false);
|
||||
setFilterToDelete(null);
|
||||
setSuccess('Фильтр удален!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.post(`${API_URL}/filters`, { domains: filters });
|
||||
setSuccess('Изменения сохранены в S3!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
setError('Не удалось сохранить изменения.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (field) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e, filterCommunity) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveEdit(filterCommunity);
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancelEdit(filterCommunity);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = (filterCommunity) => {
|
||||
const filter = filters.find(f => f.community === filterCommunity);
|
||||
if (filter && editingValues[filterCommunity]) {
|
||||
setFilters(filters.map(f =>
|
||||
f.community === filterCommunity
|
||||
? { ...f, ...editingValues[filterCommunity] }
|
||||
: f
|
||||
));
|
||||
setEditingFilter(null);
|
||||
setEditingValues({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = (filterCommunity) => {
|
||||
setEditingFilter(null);
|
||||
setEditingValues({});
|
||||
};
|
||||
|
||||
const startEdit = (filter) => {
|
||||
setEditingFilter(filter.community);
|
||||
setEditingValues({ [filter.community]: { ...filter } });
|
||||
setTimeout(() => {
|
||||
if (editInputRef.current) {
|
||||
editInputRef.current.focus();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Функция для генерации конфигурации MikroTik
|
||||
const generateMikrotikConfig = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/filters/generate-config`);
|
||||
return response.data.config;
|
||||
} catch (error) {
|
||||
console.error('Error generating config:', error);
|
||||
return '// Ошибка при генерации конфигурации';
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для предварительного просмотра конфигурации
|
||||
const handlePreviewConfig = async () => {
|
||||
const config = await generateMikrotikConfig();
|
||||
setGeneratedConfig(config);
|
||||
setPreviewModalOpen(true);
|
||||
};
|
||||
|
||||
// Функция для копирования конфигурации в буфер обмена
|
||||
const copyConfigToClipboard = async () => {
|
||||
const config = await generateMikrotikConfig();
|
||||
if (navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(config);
|
||||
setSuccess('Конфигурация скопирована в буфер обмена!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
setError('Не удалось скопировать конфигурацию');
|
||||
}
|
||||
} else {
|
||||
// Fallback для старых браузеров
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = config;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setSuccess('Конфигурация скопирована в буфер обмена!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
setError('Не удалось скопировать конфигурацию');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для экспорта конфигурации в S3
|
||||
const exportConfigToS3 = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/filters/export-config`);
|
||||
if (response.data.success) {
|
||||
setSuccess(response.data.message);
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} else {
|
||||
setError(response.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error exporting config:', error);
|
||||
setError('Не удалось экспортировать конфигурацию');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Фильтрация и сортировка
|
||||
const filteredFilters = filters
|
||||
.filter(filter => {
|
||||
const matchesSearch = filter.community.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
filter.gateway.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
filter.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesGateway = !filterGateway || filter.gateway === filterGateway;
|
||||
return matchesSearch && matchesGateway;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aValue = a[sortField] || '';
|
||||
const bValue = b[sortField] || '';
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue.localeCompare(bValue);
|
||||
} else {
|
||||
return bValue.localeCompare(aValue);
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueGateways = [...new Set(filters.map(f => f.gateway))].sort();
|
||||
|
||||
return (
|
||||
<div className="container-xl">
|
||||
<div className="page-header d-print-none mb-4">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title">Управление фильтрами frouting</h2>
|
||||
<div className="page-pretitle">Главная / Фильтры</div>
|
||||
</div>
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={handlePreviewConfig}
|
||||
>
|
||||
<IconEye className="me-2" />
|
||||
Предварительный просмотр
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={copyConfigToClipboard}
|
||||
>
|
||||
<IconCopy className="me-2" />
|
||||
Копировать конфигурацию
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-success"
|
||||
onClick={exportConfigToS3}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconDownload className="me-2" />
|
||||
Экспорт в S3
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenAddFilterModal}
|
||||
>
|
||||
<IconPlus className="me-2" />
|
||||
Добавить фильтр
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconDatabase className="me-2" />
|
||||
{loading ? 'Сохранение...' : 'Сохранить в S3'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Уведомления */}
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<IconAlertTriangle className="me-2" />
|
||||
{error}
|
||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<IconCheck className="me-2" />
|
||||
{success}
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Фильтры и поиск */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-4">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">
|
||||
<IconSearch />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск по community, gateway или описанию..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filterGateway}
|
||||
onChange={(e) => setFilterGateway(e.target.value)}
|
||||
>
|
||||
<option value="">Все gateway</option>
|
||||
{uniqueGateways.map(gateway => (
|
||||
<option key={gateway} value={gateway}>{gateway}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<button
|
||||
className="btn btn-outline-secondary w-100"
|
||||
onClick={fetchFilters}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh className="me-2" />
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица фильтров */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Фильтры frouting ({filteredFilters.length})</h3>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleSort('community')}
|
||||
>
|
||||
Community
|
||||
{sortField === 'community' && (
|
||||
<IconChevronDown className={`ms-1 ${sortOrder === 'desc' ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleSort('gateway')}
|
||||
>
|
||||
Gateway
|
||||
{sortField === 'gateway' && (
|
||||
<IconChevronDown className={`ms-1 ${sortOrder === 'desc' ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</th>
|
||||
<th>Описание</th>
|
||||
<th className="w-1">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredFilters.map((filter, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
{editingFilter === filter.community ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={editingValues[filter.community]?.community || ''}
|
||||
onChange={(e) => setEditingValues({
|
||||
...editingValues,
|
||||
[filter.community]: {
|
||||
...editingValues[filter.community],
|
||||
community: e.target.value
|
||||
}
|
||||
})}
|
||||
onKeyDown={(e) => handleEditKeyDown(e, filter.community)}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-monospace">{filter.community}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingFilter === filter.community ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={editingValues[filter.community]?.gateway || ''}
|
||||
onChange={(e) => setEditingValues({
|
||||
...editingValues,
|
||||
[filter.community]: {
|
||||
...editingValues[filter.community],
|
||||
gateway: e.target.value
|
||||
}
|
||||
})}
|
||||
onKeyDown={(e) => handleEditKeyDown(e, filter.community)}
|
||||
/>
|
||||
) : (
|
||||
<span className="badge bg-primary">{filter.gateway}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingFilter === filter.community ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={editingValues[filter.community]?.description || ''}
|
||||
onChange={(e) => setEditingValues({
|
||||
...editingValues,
|
||||
[filter.community]: {
|
||||
...editingValues[filter.community],
|
||||
description: e.target.value
|
||||
}
|
||||
})}
|
||||
onKeyDown={(e) => handleEditKeyDown(e, filter.community)}
|
||||
/>
|
||||
) : (
|
||||
<span>{filter.description}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-list">
|
||||
{editingFilter === filter.community ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleSaveEdit(filter.community)}
|
||||
>
|
||||
<IconCheck />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleCancelEdit(filter.community)}
|
||||
>
|
||||
<IconX />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => startEdit(filter)}
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => handleDelete(filter)}
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальные окна */}
|
||||
<AddFilterModal
|
||||
show={addFilterModalOpen}
|
||||
newFilter={newFilter}
|
||||
onNewFilterChange={setNewFilter}
|
||||
onAddFilter={handleAddFilterFromModal}
|
||||
onClose={handleCloseAddFilterModal}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<EditFilterModal
|
||||
show={editModalOpen}
|
||||
filter={editModalFilter}
|
||||
onChange={handleEditModalChange}
|
||||
onSave={handleEditModalSave}
|
||||
onClose={handleEditModalClose}
|
||||
/>
|
||||
|
||||
<DeleteFilterModal
|
||||
show={showDeleteModal}
|
||||
filter={filterToDelete}
|
||||
onDelete={confirmDelete}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
|
||||
<PreviewConfigModal
|
||||
show={previewModalOpen}
|
||||
config={generatedConfig}
|
||||
onClose={() => setPreviewModalOpen(false)}
|
||||
onCopy={copyConfigToClipboard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Модальное окно добавления фильтра
|
||||
function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClose, error }) {
|
||||
if (!show) return null;
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
onNewFilterChange({ ...newFilter, [field]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onAddFilter();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Добавить новый фильтр</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{error && (
|
||||
<div className="alert alert-danger">
|
||||
<IconAlertTriangle className="me-2" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="65001:200"
|
||||
value={newFilter.community}
|
||||
onChange={(e) => handleChange('community', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="form-text">Формат: AS:community (например, 65001:200)</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Gateway *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="SWE-HIPHOST"
|
||||
value={newFilter.gateway}
|
||||
onChange={(e) => handleChange('gateway', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="form-text">Название gateway для маршрутизации</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Описание</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Описание фильтра"
|
||||
value={newFilter.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
/>
|
||||
<div className="form-text">Необязательное описание фильтра</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<IconPlus className="me-2" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Модальное окно редактирования фильтра
|
||||
function EditFilterModal({ show, filter, onChange, onSave, onClose }) {
|
||||
if (!show || !filter) return null;
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
onChange({ ...filter, [field]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave(filter);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Редактировать фильтр</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="65001:200"
|
||||
value={filter.community}
|
||||
onChange={(e) => handleChange('community', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Gateway *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="SWE-HIPHOST"
|
||||
value={filter.gateway}
|
||||
onChange={(e) => handleChange('gateway', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Описание</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Описание фильтра"
|
||||
value={filter.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<IconCheck className="me-2" />
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Модальное окно удаления фильтра
|
||||
function DeleteFilterModal({ show, filter, onDelete, onClose }) {
|
||||
if (!show || !filter) return null;
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Подтверждение удаления</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>Вы уверены, что хотите удалить фильтр?</p>
|
||||
<div className="alert alert-warning">
|
||||
<strong>Community:</strong> {filter.community}<br />
|
||||
<strong>Gateway:</strong> {filter.gateway}<br />
|
||||
{filter.description && <><strong>Описание:</strong> {filter.description}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={onDelete}>
|
||||
<IconTrash className="me-2" />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Модальное окно предварительного просмотра конфигурации
|
||||
function PreviewConfigModal({ show, config, onClose, onCopy }) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Предварительный просмотр конфигурации MikroTik</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<IconCopy className="me-2" />
|
||||
Копировать в буфер обмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-dark text-light p-3 rounded" style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
<code>{config}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterManager;
|
||||
@@ -200,14 +200,35 @@ function ServerManager() {
|
||||
return `${urlSettings.baseUrl}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
// Функция резервного копирования для http
|
||||
const fallbackCopyToClipboard = (text) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
document.execCommand('copy');
|
||||
setSuccess('Ссылка скопирована в буфер обмена!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
setError('Не удалось скопировать ссылку');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
if (navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setSuccess('Ссылка скопирована в буфер обмена!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
return;
|
||||
} catch (err) {
|
||||
// fallback ниже
|
||||
}
|
||||
}
|
||||
fallbackCopyToClipboard(text);
|
||||
};
|
||||
|
||||
const quickCopyLink = async (server) => {
|
||||
|
||||
Reference in New Issue
Block a user