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
+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