feat: Implement server filters management API and integrate filter handling in FilterManager for enhanced server configuration capabilities
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m25s

This commit is contained in:
2025-07-14 11:13:35 +07:00
parent 077fdcd258
commit 022c8dddc5
2 changed files with 397 additions and 321 deletions
+131
View File
@@ -648,6 +648,137 @@ app.delete('/api/server-configs/:serverId', async (req, res) => {
}
});
// --- Server Filters Routes ---
// Get server filters
app.get('/api/server-filters/:serverId', async (req, res) => {
const { serverId } = req.params;
const params = {
Bucket: BUCKET_NAME,
Key: `server-filters-${serverId}.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 server filters:', 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 server filters from S3');
}
}
});
// Update server filters
app.post('/api/server-filters/:serverId', async (req, res) => {
const { serverId } = req.params;
const { filters } = req.body;
// 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: `server-filters-${serverId}.json`,
Body: JSON.stringify(filters, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('Server filters updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing server filters to S3');
}
});
// Generate MikroTik configuration from server filters
app.post('/api/server-filters/generate-config', async (req, res) => {
const { filters } = req.body;
if (!Array.isArray(filters) || filters.length === 0) {
return res.json({ config: '// No filters to generate configuration' });
}
// 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 filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
// Если есть только один gateway, создаем простую конфигурацию
if (Object.keys(gatewayGroups).length === 1) {
const [gateway, communities] = Object.entries(gatewayGroups)[0];
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`;
} else {
// Если несколько gateway, создаем каскадную структуру
const gatewayEntries = Object.entries(gatewayGroups);
gatewayEntries.forEach(([gateway, communities], gatewayIndex) => {
if (gatewayIndex === 0) {
config += `if (\n`;
} else {
config += `else 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}`;
if (gatewayIndex === gatewayEntries.length - 1) {
config += `\nelse\n{\n reject;\n}\n`;
} else {
config += `\n`;
}
});
}
res.json({ config });
});
// The "catchall" handler: for any request that doesn't
// match one above, send back React's index.html file.
app.get('*', (req, res) => {
+266 -321
View File
@@ -25,58 +25,51 @@ import {
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);
// Новые состояния для управления серверами
// Состояния для управления серверами
const [servers, setServers] = useState([]);
const [selectedServer, setSelectedServer] = useState(null);
const [serverConfigModalOpen, setServerConfigModalOpen] = useState(false);
const [newServer, setNewServer] = useState({ name: '', description: '', enabled: true });
const [serverConfigs, setServerConfigs] = useState({});
// Состояния для управления фильтрами сервера
const [serverFilters, setServerFilters] = useState([]);
const [newFilter, setNewFilter] = useState({ community: '', gateway: '', description: '' });
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [editFilterModalOpen, setEditFilterModalOpen] = useState(false);
const [editingFilter, setEditingFilter] = useState(null);
const [deleteFilterModalOpen, setDeleteFilterModalOpen] = useState(false);
const [filterToDelete, setFilterToDelete] = useState(null);
useEffect(() => {
fetchFilters();
fetchServers();
}, []);
const fetchFilters = async () => {
// Загружаем фильтры при выборе сервера
useEffect(() => {
if (selectedServer) {
fetchServerFilters(selectedServer.id);
} else {
setServerFilters([]);
}
}, [selectedServer]);
const fetchServerFilters = async (serverId) => {
setLoading(true);
try {
const response = await axios.get(`${API_URL}/filters`);
setFilters(response.data);
const response = await axios.get(`${API_URL}/server-filters/${serverId}`);
setServerFilters(response.data);
setError('');
} catch (error) {
console.error('Error fetching filters:', error);
setError('Не удалось загрузить фильтры. Проверьте, запущен ли бэкенд.');
console.error('Error fetching server filters:', error);
setServerFilters([]); // Если файл не существует, создаем пустой массив
} finally {
setLoading(false);
}
@@ -301,139 +294,92 @@ function FilterManager() {
}
};
const handleAddFilter = () => {
if (!newFilter.community.trim() || !newFilter.gateway.trim()) {
setError('Поля Community и Gateway должны быть заполнены.');
const handleAddFilter = async () => {
if (!selectedServer) {
setError('Сначала выберите сервер.');
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 }]);
const updatedFilters = [...serverFilters, { ...newFilter }];
setServerFilters(updatedFilters);
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!');
await axios.post(`${API_URL}/server-filters/${selectedServer.id}`, { filters: updatedFilters });
setSuccess('Фильтр успешно добавлен!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error saving filters:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
console.error('Error saving server filters:', error);
setError('Не удалось сохранить фильтр.');
}
};
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
const handleEditFilter = (filter) => {
setEditingFilter({ ...filter });
setEditFilterModalOpen(true);
};
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) => {
const handleSaveEditFilter = async (updatedFilter) => {
const updatedFilters = serverFilters.map(f =>
f.community === updatedFilter.community ? { ...updatedFilter } : f
);
setServerFilters(updatedFilters);
setEditFilterModalOpen(false);
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`);
await axios.post(`${API_URL}/server-filters/${selectedServer.id}`, { filters: updatedFilters });
setSuccess('Фильтр успешно обновлен!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error saving server filters:', error);
setError('Не удалось сохранить фильтр.');
}
};
const handleDeleteFilter = async (filter) => {
const updatedFilters = serverFilters.filter(f => f.community !== filter.community);
setServerFilters(updatedFilters);
setDeleteFilterModalOpen(false);
setFilterToDelete(null);
// Сохраняем фильтры сервера
try {
await axios.post(`${API_URL}/server-filters/${selectedServer.id}`, { filters: updatedFilters });
setSuccess('Фильтр успешно удален!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error saving server filters:', error);
setError('Не удалось удалить фильтр.');
}
};
// Удаляем старые функции редактирования
// Удаляем старые функции, которые больше не нужны
// Функция для генерации конфигурации MikroTik для выбранного сервера
const generateMikrotikConfig = async () => {
if (!selectedServer) {
return '// Сначала выберите сервер';
}
if (serverFilters.length === 0) {
return '// Нет фильтров для генерации конфигурации';
}
try {
const response = await axios.post(`${API_URL}/server-filters/generate-config`, {
filters: serverFilters
});
return response.data.config;
} catch (error) {
console.error('Error generating config:', error);
@@ -496,26 +442,7 @@ function FilterManager() {
}
};
// Фильтрация и сортировка
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">
@@ -527,43 +454,32 @@ function FilterManager() {
</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>
{selectedServer && (
<>
<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={() => handleSaveServerConfig(selectedServer.id)}
disabled={loading}
>
<IconDeviceFloppy className="me-2" />
Сохранить конфигурацию
</button>
</>
)}
</div>
</div>
</div>
@@ -648,37 +564,30 @@ function FilterManager() {
<p className="text-muted small mb-3">{server.description}</p>
)}
<div className="d-flex gap-2">
<button
className="btn btn-outline-primary btn-sm"
onClick={() => handleLoadServerConfig(server.id)}
>
<IconEye className="me-1" />
Просмотр
</button>
<button
className="btn btn-outline-success btn-sm"
onClick={() => handleSaveServerConfig(server.id)}
>
<IconDeviceFloppy className="me-1" />
Сохранить
</button>
<button
className="btn btn-outline-warning btn-sm"
onClick={() => handleCopyConfigToAllServers(server.id)}
disabled={loading || servers.filter(s => s.id !== server.id && s.enabled).length === 0}
title="Скопировать конфигурацию на все остальные серверы"
>
<IconCopy className="me-1" />
Копировать на все
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteServer(server.id)}
>
<IconTrash className="me-1" />
Удалить
</button>
</div>
<button
className={`btn btn-sm ${selectedServer?.id === server.id ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setSelectedServer(server)}
>
<IconFilter className="me-1" />
{selectedServer?.id === server.id ? 'Выбран' : 'Выбрать'}
</button>
<button
className="btn btn-outline-warning btn-sm"
onClick={() => handleCopyConfigToAllServers(server.id)}
disabled={loading || servers.filter(s => s.id !== server.id && s.enabled).length === 0}
title="Скопировать конфигурацию на все остальные серверы"
>
<IconCopy className="me-1" />
Копировать на все
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteServer(server.id)}
>
<IconTrash className="me-1" />
Удалить
</button>
</div>
</div>
</div>
</div>
@@ -690,112 +599,148 @@ function FilterManager() {
</div>
</div>
{/* Фильтры и поиск */}
<div className="card w-100">
<div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title mb-0">Список фильтров</h3>
<div className="d-flex gap-2 w-75">
{/* Фильтры и поиск */}
<select className="form-select w-auto" value={filterGateway} onChange={e => setFilterGateway(e.target.value)}>
<option value="">Все gateway</option>
{uniqueGateways.map(gateway => (
<option key={gateway} value={gateway}>{gateway}</option>
))}
</select>
<div className="input-icon flex-grow-1">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по фильтрам..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
{/* Секция фильтров выбранного сервера */}
{selectedServer && (
<div className="row mb-4">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">
<IconFilter className="me-2" />
Фильтры сервера: {selectedServer.name}
<span className="badge bg-green-lt text-green ms-2">
{serverFilters.length} фильтров
</span>
</h3>
<div className="card-actions">
<button
className="btn btn-outline-primary btn-sm me-2"
onClick={handlePreviewConfig}
>
<IconEye className="me-2" />
Предварительный просмотр
</button>
<button
className="btn btn-outline-primary btn-sm me-2"
onClick={copyConfigToClipboard}
>
<IconCopy className="me-2" />
Копировать конфигурацию
</button>
<button
className="btn btn-outline-success btn-sm me-2"
onClick={() => handleSaveServerConfig(selectedServer.id)}
disabled={loading}
>
<IconDeviceFloppy className="me-2" />
Сохранить конфигурацию
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => setAddFilterModalOpen(true)}
>
<IconPlus className="me-2" />
Добавить фильтр
</button>
</div>
</div>
<div className="card-body">
{serverFilters.length === 0 ? (
<div className="text-center text-muted py-4">
<IconFilter className="icon-lg mb-2" />
<p>Нет фильтров для этого сервера</p>
<button
className="btn btn-primary"
onClick={() => setAddFilterModalOpen(true)}
>
<IconPlus className="me-2" />
Добавить первый фильтр
</button>
</div>
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th>COMMUNITY</th>
<th>GATEWAY</th>
<th>ОПИСАНИЕ</th>
<th className="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<tbody>
{serverFilters.map((filter) => (
<tr key={filter.community}>
<td>
<span className="badge bg-blue-lt text-blue">{filter.community}</span>
</td>
<td>
<span className="badge bg-green-lt text-green">{filter.gateway}</span>
</td>
<td>{filter.description || '-'}</td>
<td className="text-end">
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => handleEditFilter(filter)}
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => {
setFilterToDelete(filter);
setDeleteFilterModalOpen(true);
}}
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('community')}>
COMMUNITY
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('gateway')}>
GATEWAY
{sortField === 'gateway' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th>ОПИСАНИЕ</th>
<th className="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<tbody>
{filteredFilters.map((filter) => (
<tr key={filter.community}>
<td>
<span className="badge bg-blue-lt text-blue">{filter.community}</span>
</td>
<td>
<span className="badge bg-green-lt text-green">{filter.gateway}</span>
</td>
<td>{filter.description || '-'}</td>
<td className="text-end">
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => handleEdit(filter)}
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => handleDelete(filter)}
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Модальные окна */}
<AddFilterModal
show={addFilterModalOpen}
newFilter={newFilter}
onNewFilterChange={setNewFilter}
onAddFilter={handleAddFilterFromModal}
onClose={handleCloseAddFilterModal}
onAddFilter={handleAddFilter}
onClose={() => {
setAddFilterModalOpen(false);
setNewFilter({ community: '', gateway: '', description: '' });
}}
error={error}
/>
<EditFilterModal
show={editModalOpen}
filter={editModalFilter}
onChange={handleEditModalChange}
onSave={handleEditModalSave}
onClose={handleEditModalClose}
show={editFilterModalOpen}
filter={editingFilter}
onChange={setEditingFilter}
onSave={handleSaveEditFilter}
onClose={() => {
setEditFilterModalOpen(false);
setEditingFilter(null);
}}
/>
<DeleteFilterModal
show={showDeleteModal}
show={deleteFilterModalOpen}
filter={filterToDelete}
onDelete={confirmDelete}
onClose={() => setShowDeleteModal(false)}
onDelete={handleDeleteFilter}
onClose={() => {
setDeleteFilterModalOpen(false);
setFilterToDelete(null);
}}
/>
<PreviewConfigModal