feat(FilterManager): add functionality to copy filter rules from another server; implement modal for server selection and enhance jumphost filtering logic
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s

This commit is contained in:
2026-01-21 18:50:55 +07:00
parent fe665ebc1f
commit dace11a407
2 changed files with 135 additions and 58 deletions
+116 -2
View File
@@ -744,6 +744,9 @@ function FilterManager() {
const [selectedFilterKeys, setSelectedFilterKeys] = useState(new Set());
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
// Состояние для копирования правил с сервера
const [copyFromServerModalOpen, setCopyFromServerModalOpen] = useState(false);
// Communities directory for autocomplete
const [communities, setCommunities] = useState([]);
// Базовая AS из настроек
@@ -937,9 +940,9 @@ function FilterManager() {
}
});
// Фильтруем только jumphost'ы (не exit-ноды)
// Фильтруем только jumphost'ы (исключаем exit-ноды, bgp и dns серверы)
const jumphosts = allServers
.filter(s => s.type !== 'exit')
.filter(s => s.type === 'jumphost')
.map(s => {
const shortName = s.dns?.split('.')[0] || s.ip;
// Ищем legacyId по имени сервера
@@ -1252,6 +1255,47 @@ function FilterManager() {
}
};
// Копирование правил с другого сервера
const handleCopyRulesFromServer = async (sourceServerId) => {
if (!selectedServer) {
setError('Сначала выберите целевой сервер.');
return;
}
const sourceServer = servers.find(s => s.id === sourceServerId);
if (!sourceServer) {
setError('Исходный сервер не найден.');
return;
}
setLoading(true);
try {
// Загружаем фильтры исходного сервера
const response = await api.get(`/server-filters/${sourceServerId}`);
const sourceFilters = Array.isArray(response.data) ? response.data : [];
if (sourceFilters.length === 0) {
setError(`У сервера "${sourceServer.name}" нет фильтров для копирования.`);
setLoading(false);
return;
}
// Копируем фильтры на текущий сервер
const updatedFilters = [...sourceFilters];
await api.post(`/server-filters/${selectedServer.id}`, { filters: updatedFilters });
setServerFilters(updatedFilters);
setCopyFromServerModalOpen(false);
setSuccess(`Скопировано ${sourceFilters.length} фильтров с сервера "${sourceServer.name}"`);
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error copying rules from server:', error);
setError('Не удалось скопировать фильтры.');
} finally {
setLoading(false);
}
};
const handleAddFilter = async () => {
if (!selectedServer) {
setError('Сначала выберите сервер.');
@@ -1968,6 +2012,15 @@ function FilterManager() {
<IconSearch size={18} />
</span>
</div>
<button
className="btn btn-outline-primary"
onClick={() => setCopyFromServerModalOpen(true)}
disabled={servers.filter(s => s.id !== selectedServer?.id).length === 0}
title="Скопировать правила с другого сервера"
>
<IconCopy className="icon me-1" />
Скопировать с сервера
</button>
<button
className="btn btn-primary"
onClick={() => setAddFilterModalOpen(true)}
@@ -2516,6 +2569,67 @@ function FilterManager() {
)}
</Modal>
{/* Модальное окно выбора сервера для копирования правил */}
<Modal
show={copyFromServerModalOpen}
onClose={() => setCopyFromServerModalOpen(false)}
title="Скопировать правила с сервера"
size="md"
>
<div className="alert alert-info mb-3">
<IconCopy className="me-2" />
Выберите сервер, с которого хотите скопировать фильтры на <strong>{selectedServer?.name}</strong>
</div>
<div className="alert alert-warning mb-3">
<IconAlertTriangle className="me-2" />
<strong>Внимание:</strong> Текущие фильтры сервера будут заменены!
</div>
<div className="list-group">
{servers
.filter(s => s.id !== selectedServer?.id)
.map(server => {
const meta = inventoryServers.find(
(inv) =>
String(inv.dns || '').trim() === server.name ||
String(inv.hostName || '').trim() === server.name ||
String(inv.ip || '').trim() === server.name
);
const flag = meta?.country ? countryToFlag(meta.country) : '';
return (
<button
key={server.id}
className="list-group-item list-group-item-action d-flex align-items-center gap-3"
onClick={() => handleCopyRulesFromServer(server.id)}
disabled={loading}
>
<span className="avatar bg-primary-lt" title={meta?.country || ''}>
{flag || <IconServer size={16} />}
</span>
<div className="flex-grow-1">
<div className="fw-bold">{server.name}</div>
<div className="text-muted small">
{meta?.dns || server.description || server.ip || ''}
</div>
</div>
<IconChevronDown size={16} className="text-muted" style={{ transform: 'rotate(-90deg)' }} />
</button>
);
})}
</div>
{servers.filter(s => s.id !== selectedServer?.id).length === 0 && (
<div className="empty py-4">
<div className="empty-icon">
<IconServer size={32} className="text-muted" />
</div>
<p className="empty-subtitle text-muted">Нет других серверов для копирования</p>
</div>
)}
</Modal>
<ConfirmDialog
open={confirmState.open}
title="Подтверждение"
+19 -56
View File
@@ -4,12 +4,11 @@ import ConfirmDialog from './components/ConfirmDialog.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import FormModal from './components/FormModal.jsx';
import FormField from './components/FormField.jsx';
import { useNotify } from './components/NotifyProvider.jsx';
import {
IconPlus,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconDownload,
IconServer,
@@ -48,12 +47,12 @@ function countryToFlag(isoCode) {
}
function ServerManager() {
const notify = useNotify();
// === Основные данные ===
const [servers, setServers] = useState([]);
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// === UI State ===
const [activeTab, setActiveTab] = useState('servers'); // 'servers' | 'connections'
@@ -152,10 +151,9 @@ function ServerManager() {
gateways: NEEDS_GATEWAYS(srv.type) ? normalizeGateways(srv.gateways, srv.gateway || srv.dns || srv.ip) : (Array.isArray(srv.gateways) ? srv.gateways : []),
}));
setServers(normalized);
setError('');
} catch (error) {
console.error('Error fetching servers:', error);
setError('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
notify.error('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
} finally {
setLoading(false);
}
@@ -317,8 +315,7 @@ function ServerManager() {
}
})();
setSuccess('Сервер успешно добавлен!');
setTimeout(() => setSuccess(''), 3000);
notify.success('Сервер успешно добавлен!');
};
const handleEdit = (server) => {
@@ -339,8 +336,7 @@ function ServerManager() {
}));
setEditModalOpen(false);
setEditModalServer(null);
setSuccess('Сервер успешно обновлён!');
setTimeout(() => setSuccess(''), 3000);
notify.success('Сервер успешно обновлён!');
};
const handleEditModalClose = () => {
@@ -359,8 +355,7 @@ function ServerManager() {
setServers(servers.filter(s => s.ip !== serverToDelete.ip));
setShowDeleteModal(false);
setServerToDelete(null);
setSuccess('Сервер удалён!');
setTimeout(() => setSuccess(''), 3000);
notify.success('Сервер удалён!');
}
};
@@ -374,8 +369,7 @@ function ServerManager() {
setServers(servers.filter(s => !selectedServers.has(s.ip)));
setSelectedServers(new Set());
setConfirmState({ open: false, text: '', onConfirm: null });
setSuccess(`Удалено ${selectedServers.size} серверов. Не забудьте сохранить!`);
setTimeout(() => setSuccess(''), 3000);
notify.success(`Удалено ${selectedServers.size} серверов. Не забудьте сохранить!`);
},
});
};
@@ -385,8 +379,7 @@ function ServerManager() {
setServers(servers.map(s =>
selectedServers.has(s.ip) ? { ...s, tunnel: newTunnel } : s
));
setSuccess(`Туннель изменён на ${newTunnel} для ${selectedServers.size} серверов. Не забудьте сохранить!`);
setTimeout(() => setSuccess(''), 3000);
notify.success(`Туннель изменён на ${newTunnel} для ${selectedServers.size} серверов. Не забудьте сохранить!`);
};
const handleBulkExport = () => {
@@ -427,8 +420,7 @@ function ServerManager() {
if (!p) return;
setUrlSettings(prev => ({ ...prev, ...p }));
localStorage.setItem('urlSettings', JSON.stringify({ ...urlSettings, ...p }));
setSuccess('Профиль применён');
setTimeout(() => setSuccess(''), 2000);
notify.success('Профиль применён');
};
// === Clipboard ===
@@ -440,10 +432,9 @@ function ServerManager() {
textArea.select();
try {
document.execCommand('copy');
setSuccess('Скопировано в буфер обмена!');
setTimeout(() => setSuccess(''), 3000);
notify.success('Скопировано в буфер обмена!');
} catch (err) {
setError('Не удалось скопировать');
notify.error('Не удалось скопировать');
}
document.body.removeChild(textArea);
};
@@ -452,8 +443,7 @@ function ServerManager() {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
setSuccess('Скопировано в буфер обмена!');
setTimeout(() => setSuccess(''), 3000);
notify.success('Скопировано в буфер обмена!');
return;
} catch (err) {
// fallback
@@ -551,11 +541,10 @@ function ServerManager() {
// Не прерываем основную операцию
}
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notify.success('Изменения успешно сохранены!');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
notify.error('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
@@ -564,17 +553,16 @@ function ServerManager() {
// === Connections ===
const addConnectionInternal = (draft) => {
if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) {
setError('Все поля для связи должны быть заполнены.');
notify.error('Все поля для связи должны быть заполнены.');
return false;
}
if (draft.from === draft.to) {
setError('Сервер A и сервер B не могут быть одинаковыми.');
notify.error('Сервер A и сервер B не могут быть одинаковыми.');
return false;
}
const next = { ...draft };
setConnections((prev) => [...prev, next]);
setSuccess('Связь успешно добавлена!');
setTimeout(() => setSuccess(''), 3000);
notify.success('Связь успешно добавлена!');
return true;
};
@@ -582,7 +570,6 @@ function ServerManager() {
const ok = addConnectionInternal(newConnection);
if (!ok) return;
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
setError('');
};
const handleConfirmGraphConnection = () => {
@@ -590,38 +577,15 @@ function ServerManager() {
if (!ok) return;
setGraphConnectionModalOpen(false);
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
setError('');
};
const handleDeleteConnection = (index) => {
setConnections(connections.filter((_, i) => i !== index));
setSuccess('Связь удалена!');
setTimeout(() => setSuccess(''), 3000);
notify.success('Связь удалена!');
};
return (
<div className="server-manager-layout">
{/* Уведомления */}
{error && (
<div className="alert alert-danger alert-dismissible" role="alert" style={{ position: 'fixed', top: 80, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, minWidth: 300 }}>
<div className="d-flex">
<IconX className="icon alert-icon" />
<div>{error}</div>
</div>
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert" style={{ position: 'fixed', top: 80, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, minWidth: 300 }}>
<div className="d-flex">
<IconCheck className="icon alert-icon" />
<div>{success}</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Header */}
<div className="page-header d-print-none mb-3">
<div className="row align-items-center">
@@ -1113,7 +1077,6 @@ function ServerManager() {
onClose={() => {
setGraphConnectionModalOpen(false);
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
setError('');
}}
onSubmit={handleConfirmGraphConnection}
title="Создать связь"