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

This commit is contained in:
2025-07-14 08:09:05 +07:00
parent a4c5b1dd98
commit 93ace3c619
4 changed files with 1027 additions and 7 deletions
+200 -3
View File
@@ -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) => {
+6 -2
View File
@@ -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>
+798
View File
@@ -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;
+23 -2
View File
@@ -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) => {