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
|
// Новый эндпоинт для получения дат последнего изменения файлов S3
|
||||||
app.get('/api/s3/last-modified', async (req, res) => {
|
app.get('/api/s3/last-modified', async (req, res) => {
|
||||||
try {
|
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: 'domains.txt' }).promise(),
|
||||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'asns.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({
|
res.json({
|
||||||
domainsLastModified: domainsHead.LastModified ? domainsHead.LastModified.toISOString() : null,
|
domainsLastModified: domainsHead.LastModified ? domainsHead.LastModified.toISOString() : null,
|
||||||
asnsLastModified: asnsHead.LastModified ? asnsHead.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) {
|
} catch (error) {
|
||||||
console.error(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
|
// The "catchall" handler: for any request that doesn't
|
||||||
// match one above, send back React's index.html file.
|
// match one above, send back React's index.html file.
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import {
|
|||||||
IconDatabase,
|
IconDatabase,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconServer
|
IconServer,
|
||||||
|
IconFilter
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import DataManager from './DataManager';
|
import DataManager from './DataManager';
|
||||||
import ServerManager from './ServerManager';
|
import ServerManager from './ServerManager';
|
||||||
|
import FilterManager from './FilterManager';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +52,7 @@ function MainLayout() {
|
|||||||
{ id: 'domains', title: 'Домены', icon: IconWorld, path: '/domains' },
|
{ id: 'domains', title: 'Домены', icon: IconWorld, path: '/domains' },
|
||||||
{ id: 'asns', title: 'AS', icon: IconNetwork, path: '/asns' },
|
{ id: 'asns', title: 'AS', icon: IconNetwork, path: '/asns' },
|
||||||
{ id: 'servers', title: 'Серверы', icon: IconServer, path: '/servers' },
|
{ id: 'servers', title: 'Серверы', icon: IconServer, path: '/servers' },
|
||||||
|
{ id: 'filters', title: 'Фильтры', icon: IconFilter, path: '/filters' },
|
||||||
{ id: 'files', title: 'Файлы', icon: IconFileText, path: '/files' },
|
{ id: 'files', title: 'Файлы', icon: IconFileText, path: '/files' },
|
||||||
{ id: 'cloud', title: 'Облако', icon: IconCloud, path: '/cloud' },
|
{ id: 'cloud', title: 'Облако', icon: IconCloud, path: '/cloud' },
|
||||||
{ id: 'security', title: 'Безопасность', icon: IconShield, path: '/security' },
|
{ id: 'security', title: 'Безопасность', icon: IconShield, path: '/security' },
|
||||||
@@ -70,7 +73,7 @@ function MainLayout() {
|
|||||||
<span className="fw-bold">S3 Lists Manager</span>
|
<span className="fw-bold">S3 Lists Manager</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="nav nav-tabs ms-4">
|
<nav className="nav nav-tabs ms-4">
|
||||||
{navItems.slice(0, 3).map(item => (
|
{navItems.slice(0, 4).map(item => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
@@ -100,6 +103,7 @@ function MainLayout() {
|
|||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
<Route path="/servers" element={<ServerManager />} />
|
<Route path="/servers" element={<ServerManager />} />
|
||||||
|
<Route path="/filters" element={<FilterManager />} />
|
||||||
<Route path="/" element={<Navigate to="/domains" replace />} />
|
<Route path="/" element={<Navigate to="/domains" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</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()}`;
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
document.execCommand('copy');
|
||||||
setSuccess('Ссылка скопирована в буфер обмена!');
|
setSuccess('Ссылка скопирована в буфер обмена!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Не удалось скопировать ссылку');
|
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) => {
|
const quickCopyLink = async (server) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user