Files
router-lists-ui/frontend/src/NetworkConfigManager.jsx
T

5470 lines
252 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from 'react';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import FormModal from './components/FormModal.jsx';
import FormField from './components/FormField.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx';
import Tooltip from './components/Tooltip.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconNetwork,
IconPlus,
IconEdit,
IconTrash,
IconDatabase,
IconServer,
IconWorld,
IconRouter,
IconCopy,
IconSearch,
IconRefresh,
IconWand,
IconSparkles,
IconFilter,
IconX,
IconCircleFilled,
IconLayoutGrid,
IconList,
IconCode,
IconArrowsRightLeft,
IconLock,
IconChevronDown,
IconChevronUp,
} from '@tabler/icons-react';
/**
* Менеджер сетевых настроек
* Справочник IP-адресов, интерфейсов и gateway для серверов
*/
// Типы провайдеров gateway
const GATEWAY_PROVIDERS = [
{ value: 'cloudflare', label: 'Cloudflare', color: 'orange' },
{ value: 'bunny', label: 'Bunny CDN', color: 'yellow' },
{ value: 'fastly', label: 'Fastly', color: 'red' },
{ value: 'telegram', label: 'Telegram', color: 'blue' },
{ value: 'hetzner', label: 'Hetzner', color: 'green' },
{ value: 'yandex', label: 'Yandex Cloud', color: 'cyan' },
{ value: 'custom', label: 'Другой', color: 'secondary' },
];
// Типы туннельных интерфейсов
const INTERFACE_TYPES = [
{ value: 'GRE', label: 'GRE', color: 'blue' },
{ value: 'WireGuard', label: 'WireGuard', color: 'green' },
{ value: 'IPSec', label: 'IPSec', color: 'yellow' },
{ value: 'VXLAN', label: 'VXLAN', color: 'purple' },
{ value: 'OpenVPN', label: 'OpenVPN', color: 'orange' },
];
// Типы gateway
const GATEWAY_TYPES = [
{ value: 'direct', label: 'Прямой', description: 'Прямой доступ в интернет', color: 'green' },
{ value: 'recursive', label: 'Рекурсивный', description: 'Ссылается на другой gateway', color: 'blue' },
];
// Шаблоны стандартных наборов gateways
const GATEWAY_TEMPLATES = [
{
id: 'standard-jumphost',
name: 'Стандартный набор для Jumphost',
description: 'Direct Gateway + Основной + EU-Шлюзы',
gateways: [
{
type: 'direct',
description: 'Direct Gateway',
ipTemplate: '{baseIp}.1', // Будет заменено на реальный IP
country: '',
},
{
type: 'recursive',
description: 'Основной',
ipTemplate: '{baseIp}.1',
country: '',
parentGateways: [], // Будет заполнено пользователем
},
{
type: 'recursive',
description: 'EU-Шлюз (VEESP/HIPHOS)',
ipTemplate: '{baseIp}.2',
country: 'SE',
parentGateways: [],
},
{
type: 'recursive',
description: 'EU-Шлюз (FIN)',
ipTemplate: '{baseIp}.3',
country: 'FI',
parentGateways: [],
},
],
},
{
id: 'minimal-set',
name: 'Минимальный набор',
description: 'Direct Gateway + Основной',
gateways: [
{
type: 'direct',
description: 'Direct Gateway',
ipTemplate: '{baseIp}.1',
country: '',
},
{
type: 'recursive',
description: 'Основной',
ipTemplate: '{baseIp}.1',
country: '',
parentGateways: [],
},
],
},
];
// Пустые объекты
const getEmptyGateway = () => ({
id: `gw-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
ip: '',
description: '',
serverId: '',
country: '',
type: 'direct',
parentGateways: [], // Для рекурсивных - массив родительских gateway с distance [{id, distance}]
});
const getEmptyInterface = () => ({
id: `if-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
name: '',
name2: '', // Имя интерфейса на втором сервере (опционально)
type: 'GRE',
localIp: '',
remoteIp: '',
serverId: '',
serverId2: '', // Второй сервер (опциональный)
ipsecPasswordId: '', // ID IPSec пароля (опционально, только для IPSec)
ipPoolId: '', // ID IP пула для выбора IP адресов (опционально)
mtu: '', // MTU (опционально)
ptrZoneReplaceFrom: '', // Что заменить в DNS домене для PTR зоны (например, "rt.shx")
ptrZoneReplaceTo: '', // На что заменить (например, "shrt")
});
const getEmptyIpPool = () => ({
id: `pool-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
name: '',
cidr: '',
description: '',
});
const getDefaultConfig = () => ({
gateways: [],
tunnelInterfaces: [],
ipPools: [],
});
function NetworkConfigManager() {
const notify = useNotify();
// === Основные данные ===
const [config, setConfig] = useState(getDefaultConfig());
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// === UI State ===
const [activeTab, setActiveTab] = useState('gateways');
const [searchTerm, setSearchTerm] = useState('');
const [providerFilter, setProviderFilter] = useState('');
const [serverFilter, setServerFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [gatewayTypeFilter, setGatewayTypeFilter] = useState(''); // Для фильтрации gateway по типу
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const [ipRegistrySearch, setIpRegistrySearch] = useState(''); // Поиск в реестре IP
const [expandedCategories, setExpandedCategories] = useState(new Set(['home', 'jumphost', 'exit'])); // Развернутые категории аккордеонов
// === Modals ===
const [gatewayModalOpen, setGatewayModalOpen] = useState(false);
const [gatewayModalMode, setGatewayModalMode] = useState('add');
const [editingGateway, setEditingGateway] = useState(null);
const [interfaceModalOpen, setInterfaceModalOpen] = useState(false);
const [interfaceModalMode, setInterfaceModalMode] = useState('add');
const [editingInterface, setEditingInterface] = useState(null);
const [poolModalOpen, setPoolModalOpen] = useState(false);
const [poolModalMode, setPoolModalMode] = useState('add');
const [editingPool, setEditingPool] = useState(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [deleteType, setDeleteType] = useState('');
// === Gateway Template Modal ===
const [gatewayTemplateModalOpen, setGatewayTemplateModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState(null);
const [templateServerId, setTemplateServerId] = useState('');
const [templateBaseIp, setTemplateBaseIp] = useState(''); // Базовый IP для генерации (например, 10.9.9)
const [templateGateways, setTemplateGateways] = useState([]); // Массив gateways из шаблона с возможностью редактирования
// === Interface Template Modal ===
const [interfaceTemplateModalOpen, setInterfaceTemplateModalOpen] = useState(false);
const [templateServer1, setTemplateServer1] = useState('');
const [templateServer2, setTemplateServer2] = useState('');
const [templateType, setTemplateType] = useState('GRE');
const [templateNamePrefix, setTemplateNamePrefix] = useState('');
const [templateName1, setTemplateName1] = useState(''); // Опциональное имя для сервера 1
const [templateName2, setTemplateName2] = useState(''); // Опциональное имя для сервера 2
const [templateIpsecPasswordId, setTemplateIpsecPasswordId] = useState(''); // Выбранный IPSec пароль
const [templateIpPoolId, setTemplateIpPoolId] = useState(''); // Выбранный IP пул
const [templateMtu, setTemplateMtu] = useState(''); // MTU для шаблона
const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания
const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания
// === IPSec Passwords Management ===
const [ipsecPasswords, setIpsecPasswords] = useState([]);
const [ipsecPasswordModalOpen, setIpsecPasswordModalOpen] = useState(false);
const [ipsecPasswordsListModalOpen, setIpsecPasswordsListModalOpen] = useState(false);
const [editingIpsecPassword, setEditingIpsecPassword] = useState(null);
const [ipsecPasswordModalMode, setIpsecPasswordModalMode] = useState('add'); // 'add' | 'edit'
// === MikroTik Code Generation ===
const [mikrotikCodeModalOpen, setMikrotikCodeModalOpen] = useState(false);
const [generatedMikrotikCode, setGeneratedMikrotikCode] = useState([]);
// === Загрузка данных ===
useEffect(() => {
fetchConfig();
fetchServers();
fetchIpsecPasswords();
}, []);
const fetchConfig = async () => {
setLoading(true);
try {
const response = await api.get('/network-config');
const data = response.data || getDefaultConfig();
// Миграция старых данных: преобразуем parentGatewayId в parentGateways
const migratedGateways = (data.gateways || []).map(gw => {
if (gw.type === 'recursive' && gw.parentGatewayId && (!gw.parentGateways || gw.parentGateways.length === 0)) {
return {
...gw,
parentGateways: [{ id: gw.parentGatewayId, distance: undefined }],
parentGatewayId: undefined // Удаляем старое поле
};
}
return gw;
});
setConfig({
gateways: Array.isArray(migratedGateways) ? migratedGateways : [],
tunnelInterfaces: Array.isArray(data.tunnelInterfaces) ? data.tunnelInterfaces : [],
ipPools: Array.isArray(data.ipPools) ? data.ipPools : [],
});
} catch (error) {
if (error.response?.status !== 404) {
console.error('Error fetching network config:', error);
notify.error('Не удалось загрузить сетевые настройки');
}
setConfig(getDefaultConfig());
} finally {
setLoading(false);
}
};
const fetchServers = async () => {
try {
const response = await api.get('/servers');
setServers(Array.isArray(response.data) ? response.data : []);
} catch (error) {
console.error('Error fetching servers:', error);
}
};
const fetchIpsecPasswords = async () => {
try {
const response = await api.get('/ipsec-passwords');
setIpsecPasswords(Array.isArray(response.data) ? response.data : []);
} catch (error) {
console.error('Error fetching IPSec passwords:', error);
setIpsecPasswords([]);
}
};
// === Сохранение ===
const handleSave = async () => {
setSaving(true);
try {
await api.post('/network-config', { domains: config });
notify.success('Сетевые настройки сохранены');
} catch (error) {
console.error('Error saving network config:', error);
notify.error('Не удалось сохранить настройки');
} finally {
setSaving(false);
}
};
// === Получение информации о сервере ===
const getServerInfo = (serverId) => {
if (!serverId) return null;
return servers.find(s => s.id === serverId || s.ip === serverId || s.dns === serverId);
};
const getServerLabel = (serverId) => {
const server = getServerInfo(serverId);
if (!server) return serverId || '—';
return server.dns || server.ip;
};
// === Определение категории сервера ===
const getServerCategory = (serverId) => {
if (!serverId || serverId === '__unassigned__') return 'other';
const server = getServerInfo(serverId);
if (!server) return 'other';
const serverType = server.type?.toLowerCase();
if (serverType === 'home') return 'home';
if (serverType === 'jumphost') return 'jumphost';
if (serverType === 'exit') return 'exit';
return 'other';
};
// === Получение названия категории ===
const getCategoryLabel = (category) => {
switch (category) {
case 'home':
return 'Домашние (входные роутеры)';
case 'jumphost':
return 'Jumphosts';
case 'exit':
return 'Exit nodes';
default:
return 'Прочие';
}
};
// === Переключение аккордеона категории ===
const toggleCategory = (category) => {
setExpandedCategories(prev => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
};
// === Уникальные серверы для фильтра ===
const uniqueServersInConfig = useMemo(() => {
const allServerIds = new Set([
...(config.gateways || []).map(g => g.serverId).filter(Boolean),
...(config.tunnelInterfaces || []).map(i => i.serverId).filter(Boolean),
...(config.ipPools || []).map(p => p.serverId).filter(Boolean),
]);
return Array.from(allServerIds).map(id => ({
id,
label: getServerLabel(id),
server: getServerInfo(id),
}));
}, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]);
// === Фильтрация ===
const filteredGateways = useMemo(() => {
let result = [...(config.gateways || [])];
if (providerFilter) {
// Фильтруем по провайдеру сервера
result = result.filter(g => {
const server = getServerInfo(g.serverId);
return server?.provider === providerFilter;
});
}
if (serverFilter) {
result = result.filter(g => g.serverId === serverFilter);
}
if (gatewayTypeFilter) {
result = result.filter(g => g.type === gatewayTypeFilter);
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter(g => {
const server = getServerInfo(g.serverId);
return (
g.ip?.toLowerCase().includes(term) ||
g.country?.toLowerCase().includes(term) ||
server?.country?.toLowerCase().includes(term) ||
server?.provider?.toLowerCase().includes(term) ||
g.description?.toLowerCase().includes(term) ||
getServerLabel(g.serverId).toLowerCase().includes(term)
);
});
}
return result;
}, [config.gateways, providerFilter, serverFilter, typeFilter, searchTerm, servers]);
const filteredInterfaces = useMemo(() => {
let result = [...(config.tunnelInterfaces || [])];
if (typeFilter) {
result = result.filter(i => i.type === typeFilter);
}
if (serverFilter) {
result = result.filter(i => i.serverId === serverFilter);
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter(i =>
i.name?.toLowerCase().includes(term) ||
i.localIp?.toLowerCase().includes(term) ||
i.remoteIp?.toLowerCase().includes(term) ||
i.type?.toLowerCase().includes(term) ||
getServerLabel(i.serverId).toLowerCase().includes(term)
);
}
return result;
}, [config.tunnelInterfaces, typeFilter, serverFilter, searchTerm, servers]);
const filteredPools = useMemo(() => {
let result = [...(config.ipPools || [])];
if (serverFilter) {
result = result.filter(p => p.serverId === serverFilter);
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter(p => {
const server = getServerInfo(p.serverId);
return (
p.name?.toLowerCase().includes(term) ||
p.cidr?.toLowerCase().includes(term) ||
p.description?.toLowerCase().includes(term) ||
getServerLabel(p.serverId).toLowerCase().includes(term)
);
});
}
return result;
}, [config.ipPools, serverFilter, searchTerm, servers]);
// === Группировка по серверам ===
const gatewaysByServer = useMemo(() => {
const grouped = {};
filteredGateways.forEach(gw => {
const key = gw.serverId || '__unassigned__';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(gw);
});
return grouped;
}, [filteredGateways]);
const interfacesByServer = useMemo(() => {
const grouped = {};
filteredInterfaces.forEach(iface => {
// Группируем по первому серверу
const key1 = iface.serverId || '__unassigned__';
if (!grouped[key1]) grouped[key1] = [];
grouped[key1].push(iface);
// Если есть второй сервер, также добавляем в его группу
if (iface.serverId2) {
const key2 = iface.serverId2;
if (!grouped[key2]) grouped[key2] = [];
grouped[key2].push(iface);
}
});
return grouped;
}, [filteredInterfaces]);
// === Группировка по категориям серверов ===
const gatewaysByCategory = useMemo(() => {
const grouped = {
home: {},
jumphost: {},
exit: {},
other: {}
};
Object.entries(gatewaysByServer).forEach(([serverId, gateways]) => {
const category = getServerCategory(serverId);
if (!grouped[category][serverId]) {
grouped[category][serverId] = [];
}
grouped[category][serverId] = gateways;
});
return grouped;
}, [gatewaysByServer, servers]);
const interfacesByCategory = useMemo(() => {
const grouped = {
home: {},
jumphost: {},
exit: {},
other: {}
};
Object.entries(interfacesByServer).forEach(([serverId, interfaces]) => {
const category = getServerCategory(serverId);
if (!grouped[category][serverId]) {
grouped[category][serverId] = [];
}
grouped[category][serverId] = interfaces;
});
return grouped;
}, [interfacesByServer, servers]);
// === Сброс фильтров ===
const resetFilters = () => {
setSearchTerm('');
setProviderFilter('');
setServerFilter('');
setTypeFilter('');
setGatewayTypeFilter('');
};
const hasActiveFilters = searchTerm || providerFilter || serverFilter || typeFilter || gatewayTypeFilter;
// === Обработка шаблонов gateways ===
const handleOpenGatewayTemplate = () => {
setSelectedTemplate(null);
setTemplateServerId('');
setTemplateBaseIp('');
setTemplateGateways([]);
setGatewayTemplateModalOpen(true);
};
// Нормализация базового IP: если введен полный IP (4 октета), берем только первые 3 октета
const normalizeBaseIp = (baseIp) => {
if (!baseIp || !baseIp.trim()) return '';
const trimmed = baseIp.trim();
const parts = trimmed.split('.');
// Если 4 октета, возвращаем первые 3
if (parts.length === 4) {
return parts.slice(0, 3).join('.');
}
// Если 3 октета или меньше, возвращаем как есть
return trimmed;
};
const handleSelectTemplate = (template) => {
setSelectedTemplate(template);
// Инициализируем gateways из шаблона
const initializedGateways = template.gateways.map((gw, index) => {
let ip = '';
// Для прямых gateways используем IP сервера (если выбран сервер)
if (gw.type === 'direct' && templateServerId) {
const server = getServerInfo(templateServerId);
if (server && server.ip) {
ip = server.ip;
}
}
// Для рекурсивных gateways генерируем из базового IP
else if (gw.type === 'recursive' && templateBaseIp && gw.ipTemplate) {
const normalizedBaseIp = normalizeBaseIp(templateBaseIp);
if (normalizedBaseIp && normalizedBaseIp.split('.').length >= 3) {
ip = gw.ipTemplate.replace('{baseIp}', normalizedBaseIp);
}
}
return {
...gw,
id: `template-gw-${index}`,
ip: ip,
description: gw.description || '',
country: gw.country || '',
serverId: templateServerId, // Сохраняем serverId для фильтрации
parentGateways: gw.parentGateways ? [...gw.parentGateways] : [],
};
});
setTemplateGateways(initializedGateways);
};
// Обновление IP адресов при изменении базового IP или сервера
useEffect(() => {
if (selectedTemplate && templateGateways.length > 0) {
const updatedGateways = templateGateways.map((gw, index) => {
const originalTemplate = selectedTemplate.gateways[index];
// Обновляем serverId для всех gateways при изменении сервера
const updatedGateway = { ...gw, serverId: templateServerId };
// Для прямых gateways используем IP сервера
if (gw.type === 'direct' && templateServerId) {
const server = getServerInfo(templateServerId);
if (server && server.ip) {
return { ...updatedGateway, ip: server.ip };
}
}
// Для рекурсивных gateways генерируем из базового IP
else if (gw.type === 'recursive' && templateBaseIp && originalTemplate && originalTemplate.ipTemplate) {
const normalizedBaseIp = normalizeBaseIp(templateBaseIp);
if (normalizedBaseIp && normalizedBaseIp.split('.').length >= 3) {
const templateIp = originalTemplate.ipTemplate.replace('{baseIp}', normalizedBaseIp);
// Обновляем IP только если он соответствует шаблону (последний октет совпадает) или пустой
const currentIpParts = gw.ip ? gw.ip.split('.') : [];
const templateIpParts = templateIp.split('.');
// Если IP пустой или последний октет совпадает с шаблоном, обновляем
if (!gw.ip || (currentIpParts.length === 4 && templateIpParts.length === 4 &&
currentIpParts[3] === templateIpParts[3])) {
return { ...updatedGateway, ip: templateIp };
}
}
}
return updatedGateway;
});
// Обновляем только если есть изменения
const hasChanges = updatedGateways.some((gw, i) => gw.ip !== templateGateways[i]?.ip || gw.serverId !== templateGateways[i]?.serverId);
if (hasChanges) {
setTemplateGateways(updatedGateways);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateBaseIp, templateServerId, selectedTemplate?.id]);
const handleCreateGatewaysFromTemplate = () => {
if (!selectedTemplate) {
notify.error('Выберите шаблон');
return;
}
if (!templateServerId) {
notify.error('Выберите сервер');
return;
}
if (!templateBaseIp || !templateBaseIp.trim()) {
notify.error('Укажите базовый IP адрес (например, 10.9.9 или 45.8.248)');
return;
}
// Нормализуем базовый IP (берем первые 3 октета, если введен полный IP)
const normalizedBaseIp = normalizeBaseIp(templateBaseIp);
const baseIpParts = normalizedBaseIp.split('.');
if (baseIpParts.length < 3) {
notify.error('Базовый IP должен содержать минимум 3 октета (например, 10.9.9)');
return;
}
const newGateways = [];
// Для проверки конфликтов используем только IP из существующих gateways
// Рекурсивные gateways могут использовать те же IP, что и интерфейсы - это нормально
const usedGatewayIps = new Set(config.gateways.map(g => g.ip).filter(Boolean));
// Создаем маппинг между ID из шаблона и реальными ID после создания
const templateIdToRealId = new Map();
templateGateways.forEach((gwTemplate, index) => {
let ip = gwTemplate.ip || '';
// Для прямых gateways используем IP сервера
if (gwTemplate.type === 'direct' && templateServerId) {
const server = getServerInfo(templateServerId);
if (server && server.ip) {
ip = server.ip;
}
}
// Для рекурсивных gateways генерируем из базового IP
else if (gwTemplate.type === 'recursive' && !ip && selectedTemplate.gateways[index]?.ipTemplate) {
const normalizedBaseIp = normalizeBaseIp(templateBaseIp);
if (normalizedBaseIp && normalizedBaseIp.split('.').length >= 3) {
ip = selectedTemplate.gateways[index].ipTemplate.replace('{baseIp}', normalizedBaseIp);
}
}
// Если IP все еще пустой, пропускаем
if (!ip || !ip.trim()) {
notify.warning(`Gateway ${index + 1}: IP адрес не указан, пропущен`);
return;
}
ip = ip.trim();
// Проверяем конфликты только с другими gateways
// Для рекурсивных gateways разрешено использовать IP, которые уже используются в интерфейсах
if (usedGatewayIps.has(ip)) {
notify.warning(`IP ${ip} уже используется в другом gateway, пропущен`);
return;
}
const newGatewayId = `gw-${Date.now()}-${index}-${Math.random().toString(16).slice(2, 6)}`;
// Сохраняем маппинг ID
templateIdToRealId.set(gwTemplate.id, newGatewayId);
const newGateway = {
id: newGatewayId,
ip: ip,
description: gwTemplate.description || '',
serverId: templateServerId,
country: gwTemplate.country || '',
type: gwTemplate.type,
parentGateways: gwTemplate.type === 'recursive'
? (gwTemplate.parentGateways || []).filter(p => p && p.id).map(p => {
// Преобразуем ID родительского gateway из шаблона в реальный ID
const realParentId = templateIdToRealId.get(p.id) || p.id;
return { ...p, id: realParentId };
})
: [],
};
newGateways.push(newGateway);
// Добавляем IP в список использованных для проверки конфликтов внутри этой же операции создания
usedGatewayIps.add(ip);
});
if (newGateways.length === 0) {
notify.error('Не удалось создать ни одного gateway');
return;
}
// Добавляем все gateways
setConfig(prev => ({
...prev,
gateways: [...prev.gateways, ...newGateways],
}));
notify.success(`Создано gateways: ${newGateways.length}`);
// Закрываем модал и сбрасываем значения
setGatewayTemplateModalOpen(false);
setSelectedTemplate(null);
setTemplateServerId('');
setTemplateBaseIp('');
setTemplateGateways([]);
};
// === CRUD для Gateways ===
const handleAddGateway = () => {
setEditingGateway(getEmptyGateway());
setGatewayModalMode('add');
setGatewayModalOpen(true);
};
const handleEditGateway = (gateway) => {
// Миграция старых данных при редактировании
const migratedGateway = gateway.type === 'recursive' && gateway.parentGatewayId && (!gateway.parentGateways || gateway.parentGateways.length === 0)
? {
...gateway,
parentGateways: [{ id: gateway.parentGatewayId, distance: undefined }]
}
: {
...gateway,
parentGateways: gateway.parentGateways || []
};
setEditingGateway(migratedGateway);
setGatewayModalMode('edit');
setGatewayModalOpen(true);
};
const handleSaveGateway = (gatewayData) => {
// Валидация: IP адрес обязателен
if (!gatewayData.ip || !gatewayData.ip.trim()) {
notify.error('IP адрес обязателен');
return;
}
// Очищаем пустые parentGateways и удаляем старое поле parentGatewayId
const cleanedData = {
...gatewayData,
parentGateways: gatewayData.type === 'recursive'
? (gatewayData.parentGateways || []).filter(p => p && p.id)
: [],
parentGatewayId: undefined // Удаляем старое поле
};
if (gatewayModalMode === 'add') {
// Проверка на дубликат IP
const existingGateway = config.gateways.find(g => g.ip === cleanedData.ip.trim());
if (existingGateway) {
notify.error(`Gateway с IP ${cleanedData.ip} уже существует`);
return;
}
setConfig(prev => ({
...prev,
gateways: [...prev.gateways, cleanedData],
}));
notify.success('Gateway добавлен');
} else {
// При редактировании ищем по ID, но проверяем, не занят ли новый IP другим gateway
const currentGateway = config.gateways.find(g => g.id === cleanedData.id);
if (!currentGateway) {
notify.error('Gateway не найден');
return;
}
// Если IP изменился, проверяем, не занят ли он другим gateway
if (cleanedData.ip.trim() !== currentGateway.ip &&
config.gateways.some(g => g.id !== cleanedData.id && g.ip === cleanedData.ip.trim())) {
notify.error(`Gateway с IP ${cleanedData.ip} уже существует`);
return;
}
setConfig(prev => ({
...prev,
gateways: prev.gateways.map(g => g.id === cleanedData.id ? cleanedData : g),
}));
notify.success('Gateway обновлён');
}
setGatewayModalOpen(false);
setEditingGateway(null);
};
const handleDeleteGateway = (gateway) => {
setItemToDelete(gateway);
setDeleteType('gateway');
setDeleteModalOpen(true);
};
// === Генерация имени интерфейса на основе серверов ===
// Для сервера 1: берем часть из DNS сервера 2 (например, msk) и имя хоста сервера 2 (ihor)
// Для сервера 2: берем часть из DNS сервера 1 (например, swe) и имя хоста сервера 1 (hiphost)
const generateInterfaceName = (currentServer, otherServer, type, prefix = '') => {
// Парсим DNS имена
const parseDnsParts = (dns) => {
if (!dns) return null;
const parts = dns.split('.');
if (parts.length >= 2) {
return {
hostname: parts[0], // первая часть (имя хоста)
location: parts[1] // вторая часть (локация, например msk, swe)
};
}
return null;
};
const currentParts = parseDnsParts(currentServer?.dns);
const otherParts = parseDnsParts(otherServer?.dns);
let name;
if (currentParts && otherParts) {
// Используем локацию из другого сервера и имя хоста из другого сервера
// Например: для сервера 1 (hiphost.swe.shx.su) с сервером 2 (ihor.msk.rt.shx.su)
// Имя на сервере 1: msk-ihor
name = `${otherParts.location}-${otherParts.hostname}`;
} else if (currentServer?.dns) {
// Fallback: используем первую часть DNS
name = currentServer.dns.split('.')[0];
} else if (currentServer?.ip) {
// Fallback: используем IP
name = currentServer.ip.split('.').join('-');
} else {
name = 'server';
}
const prefixPart = prefix ? `${prefix}-` : '';
return `${prefixPart}${name}`.toUpperCase();
};
// === Создание интерфейса из шаблона ===
const handleCreateInterfaceFromTemplate = () => {
const tunnelCount = Math.max(1, Math.min(100, parseInt(templateTunnelCount) || 1)); // Ограничиваем от 1 до 100
// Если количество туннелей больше 1, используем выбранные пары серверов
if (tunnelCount > 1) {
// Проверяем, что все пары заполнены
if (templateServerPairs.length !== tunnelCount) {
notify.error(`Необходимо настроить ${tunnelCount} пар серверов`);
return;
}
const invalidPairs = templateServerPairs.filter((pair, index) => {
if (!pair.server1 || !pair.server2) {
return true;
}
if (pair.server1 === pair.server2) {
return true;
}
return false;
});
if (invalidPairs.length > 0) {
notify.error('Не все пары серверов настроены корректно. Проверьте, что выбраны разные серверы для каждой пары.');
return;
}
const newInterfaces = [];
const usedIps = new Set(getAllUsedIpsSet); // Создаем Set с уже использованными IP из существующих интерфейсов
// Создаем туннели для каждой пары
templateServerPairs.forEach((pair, index) => {
const server1 = getServerInfo(pair.server1);
const server2 = getServerInfo(pair.server2);
if (!server1 || !server2) {
notify.error(`Не удалось найти информацию о серверах для пары ${index + 1}`);
return;
}
// Генерируем имена интерфейсов
const baseName1 = generateInterfaceName(server1, server2, templateType, templateNamePrefix);
const baseName2 = generateInterfaceName(server2, server1, templateType, templateNamePrefix);
const name1 = (pair.name1?.trim() || baseName1).toUpperCase();
const name2 = (pair.name2?.trim() || baseName2).toUpperCase();
// Используем настройки из пары или глобальные для MTU
const pairMtu = pair.mtu || templateMtu;
// Используем настройки из пары или глобальные
const pairIpsecPasswordId = pair.ipsecPasswordId || templateIpsecPasswordId;
const pairIpPoolId = pair.ipPoolId || templateIpPoolId;
// Генерируем IP адреса (учитываем уже использованные в этой итерации)
// Важно: Local IP и Remote IP должны быть разными и в одной сети /30
// Передаем usedIps в функцию, чтобы она учитывала IP, выделенные для предыдущих туннелей
let localIp = null;
let remoteIp = null;
let attempts = 0;
const maxAttempts = 1000;
while ((!localIp || !remoteIp || localIp === remoteIp) && attempts < maxAttempts) {
const candidateLocal = generateFreePrivateIp(null, false, null, pairIpPoolId || null, usedIps);
if (candidateLocal && !usedIps.has(candidateLocal)) {
const candidateRemote = generateFreePrivateIp(null, true, candidateLocal, pairIpPoolId || null, usedIps);
if (candidateRemote && !usedIps.has(candidateRemote) && candidateRemote !== candidateLocal) {
localIp = candidateLocal;
remoteIp = candidateRemote;
usedIps.add(localIp);
usedIps.add(remoteIp);
break;
}
}
attempts++;
}
if (!localIp || !remoteIp || localIp === remoteIp) {
notify.error(`Не удалось найти свободные IP адреса для туннеля ${index + 1} (${server1.dns || server1.ip}${server2.dns || server2.ip}). Возможно, в пуле закончились свободные адреса.`);
return;
}
// Создаем интерфейс
const newInterface = {
...getEmptyInterface(),
name: name1,
name2: name2,
type: templateType,
localIp: localIp,
remoteIp: remoteIp,
serverId: pair.server1,
serverId2: pair.server2,
ipsecPasswordId: (templateType === 'IPSec' || templateType === 'GRE') && pairIpsecPasswordId ? pairIpsecPasswordId : '',
ipPoolId: pairIpPoolId || '',
mtu: pairMtu || ''
};
// Проверяем конфликты с существующими интерфейсами
const conflicts = checkInterfaceIpConflict(newInterface);
if (conflicts.length > 0) {
notify.error(`Обнаружены конфликты IP адресов для туннеля ${index + 1}. Попробуйте еще раз.`);
return;
}
newInterfaces.push(newInterface);
});
if (newInterfaces.length === 0) {
return; // Ошибки уже показаны выше
}
// Добавляем все созданные интерфейсы
setConfig(prev => ({
...prev,
tunnelInterfaces: [...prev.tunnelInterfaces, ...newInterfaces],
}));
notify.success(`Создано туннелей: ${newInterfaces.length}`);
// Закрываем модал и сбрасываем значения
setInterfaceTemplateModalOpen(false);
setTemplateServer1('');
setTemplateServer2('');
setTemplateType('GRE');
setTemplateNamePrefix('');
setTemplateName1('');
setTemplateName2('');
setTemplateIpsecPasswordId('');
setTemplateIpPoolId('');
setTemplateMtu('');
setTemplateTunnelCount(1);
setTemplateServerPairs([]);
return;
}
// Если количество туннелей = 1, используем старую логику с выбором двух серверов
if (!templateServer1 || !templateServer2) {
notify.error('Выберите оба сервера');
return;
}
if (templateServer1 === templateServer2) {
notify.error('Серверы должны быть разными');
return;
}
const server1 = getServerInfo(templateServer1);
const server2 = getServerInfo(templateServer2);
if (!server1 || !server2) {
notify.error('Не удалось найти информацию о серверах');
return;
}
// Генерируем имена интерфейсов (используем опциональные имена если указаны)
const name1 = (templateName1.trim() || generateInterfaceName(server1, server2, templateType, templateNamePrefix)).toUpperCase();
const name2 = (templateName2.trim() || generateInterfaceName(server2, server1, templateType, templateNamePrefix)).toUpperCase();
// Генерируем IP адреса
const localIp = generateFreePrivateIp(null, false, null, templateIpPoolId || null);
if (!localIp) {
notify.error('Не удалось найти свободный Local IP адрес');
return;
}
const remoteIp = generateFreePrivateIp(null, true, localIp, templateIpPoolId || null);
if (!remoteIp || remoteIp === localIp) {
notify.error('Не удалось найти свободный Remote IP адрес (отличающийся от Local IP)');
return;
}
// Проверяем конфликты
const newInterface = {
...getEmptyInterface(),
name: name1,
name2: name2,
type: templateType,
localIp: localIp,
remoteIp: remoteIp,
serverId: templateServer1,
serverId2: templateServer2,
ipsecPasswordId: (templateType === 'IPSec' || templateType === 'GRE') && templateIpsecPasswordId ? templateIpsecPasswordId : '',
ipPoolId: templateIpPoolId || '',
mtu: templateMtu || ''
};
const conflicts = checkInterfaceIpConflict(newInterface);
if (conflicts.length > 0) {
notify.error('Обнаружены конфликты IP адресов. Попробуйте еще раз.');
return;
}
// Создаем интерфейс
setConfig(prev => ({
...prev,
tunnelInterfaces: [...prev.tunnelInterfaces, newInterface],
}));
notify.success(`Интерфейс создан: ${name1} (${localIp}${remoteIp})`);
// Закрываем модал и сбрасываем значения
setInterfaceTemplateModalOpen(false);
setTemplateServer1('');
setTemplateServer2('');
setTemplateType('GRE');
setTemplateNamePrefix('');
setTemplateName1('');
setTemplateName2('');
setTemplateIpsecPasswordId('');
setTemplateIpPoolId('');
setTemplateMtu('');
setTemplateTunnelCount(1);
};
// === CRUD для Interfaces ===
const handleAddInterface = () => {
setEditingInterface(getEmptyInterface());
setInterfaceModalMode('add');
setInterfaceModalOpen(true);
};
const handleEditInterface = (iface) => {
setEditingInterface({ ...iface });
setInterfaceModalMode('edit');
setInterfaceModalOpen(true);
};
// === Проверка пересечений IP адресов для интерфейсов ===
const checkInterfaceIpConflict = (ifaceData, excludeId = null) => {
const conflicts = [];
const allInterfaces = config.tunnelInterfaces || [];
// Проверяем localIp
if (ifaceData.localIp && ifaceData.localIp.trim()) {
const conflictingLocal = allInterfaces.find(i =>
i.id !== excludeId &&
(i.localIp === ifaceData.localIp.trim() || i.remoteIp === ifaceData.localIp.trim())
);
if (conflictingLocal) {
conflicts.push({
ip: ifaceData.localIp.trim(),
type: 'localIp',
conflictingInterface: conflictingLocal
});
}
}
// Проверяем remoteIp
if (ifaceData.remoteIp && ifaceData.remoteIp.trim()) {
const conflictingRemote = allInterfaces.find(i =>
i.id !== excludeId &&
(i.localIp === ifaceData.remoteIp.trim() || i.remoteIp === ifaceData.remoteIp.trim())
);
if (conflictingRemote) {
conflicts.push({
ip: ifaceData.remoteIp.trim(),
type: 'remoteIp',
conflictingInterface: conflictingRemote
});
}
}
return conflicts;
};
// === Получение множества всех используемых IP адресов ===
const getAllUsedIpsSet = useMemo(() => {
const usedIps = new Set();
(config.tunnelInterfaces || []).forEach(iface => {
if (iface.localIp && iface.localIp.trim()) {
usedIps.add(iface.localIp.trim());
}
if (iface.remoteIp && iface.remoteIp.trim()) {
usedIps.add(iface.remoteIp.trim());
}
});
return usedIps;
}, [config.tunnelInterfaces]);
// === Генерация свободного IP адреса из приватных диапазонов ===
// Генерирует IP в сетях /30 (четные базовые адреса)
// Если указан ipPoolId, использует IP пул, иначе использует приватные диапазоны RFC 1918
// usedIpsOverride - опциональный Set уже использованных IP (если не передан, создается копия getAllUsedIpsSet)
const generateFreePrivateIp = (excludeIp = null, forRemote = false, pairedLocalIp = null, ipPoolId = null, usedIpsOverride = null) => {
// Создаем копию Set, чтобы не модифицировать оригинальный
const usedIps = usedIpsOverride || new Set(getAllUsedIpsSet);
if (excludeIp) {
usedIps.delete(excludeIp.trim());
}
// Если указан IP пул, используем его
if (ipPoolId) {
const pool = (config.ipPools || []).find(p => p.id === ipPoolId);
if (pool && pool.cidr) {
return generateIpFromPool(pool.cidr, usedIps, excludeIp, forRemote, pairedLocalIp);
}
}
// Если подбираем Remote IP и есть Local IP, генерируем парный IP в той же сети /30
if (forRemote && pairedLocalIp) {
const localIpParts = pairedLocalIp.trim().split('.');
if (localIpParts.length === 4) {
const baseOctet = parseInt(localIpParts[3]);
// Определяем базовый адрес сети /30 (округляем вниз до кратного 4)
const networkBase = Math.floor(baseOctet / 4) * 4;
const localOffset = baseOctet - networkBase;
// Если Local IP уже .1, то Remote должен быть .2
// Если Local IP .2, то Remote должен быть .1
let remoteOffset;
if (localOffset === 1) {
remoteOffset = 2;
} else if (localOffset === 2) {
remoteOffset = 1;
} else {
// Если Local IP не .1 или .2, используем стандартную логику
remoteOffset = localOffset === 0 ? 2 : (localOffset === 3 ? 1 : 2);
}
const remoteOctet = networkBase + remoteOffset;
if (remoteOctet >= 0 && remoteOctet <= 255) {
const remoteIp = `${localIpParts[0]}.${localIpParts[1]}.${localIpParts[2]}.${remoteOctet}`;
if (!usedIps.has(remoteIp)) {
return remoteIp;
}
}
}
}
// Приватные диапазоны для туннелей (RFC 1918)
// Генерируем пары IP в сетях /30 (базовый адрес кратен 4)
const ranges = [
{ start: [10, 10, 0, 0], end: [10, 10, 255, 252] }, // 10.10.0.0/16
{ start: [10, 0, 0, 0], end: [10, 255, 255, 252] }, // 10.0.0.0/8
{ start: [172, 16, 0, 0], end: [172, 31, 255, 252] }, // 172.16.0.0/12
{ start: [192, 168, 0, 0], end: [192, 168, 255, 252] }, // 192.168.0.0/16
];
for (const range of ranges) {
const [a, b, c, d] = range.start;
const [aEnd, bEnd, cEnd, dEnd] = range.end;
// Перебираем IP в диапазоне с шагом 4 (сети /30)
for (let aVal = a; aVal <= aEnd; aVal++) {
const bStart = (aVal === a) ? b : 0;
const bEndVal = (aVal === aEnd) ? bEnd : 255;
for (let bVal = bStart; bVal <= bEndVal; bVal++) {
const cStart = (aVal === a && bVal === b) ? c : 0;
const cEndVal = (aVal === aEnd && bVal === bEnd) ? cEnd : 255;
for (let cVal = cStart; cVal <= cEndVal; cVal++) {
const dStart = (aVal === a && bVal === b && cVal === c) ? d : 0;
const dEndVal = (aVal === aEnd && bVal === bEnd && cVal === cEnd) ? dEnd : 252;
// Перебираем только базовые адреса сетей /30 (кратные 4)
for (let dVal = dStart; dVal <= dEndVal; dVal += 4) {
// Local IP = базовый + 1, Remote IP = базовый + 2
// НЕ используем базовый (dVal) и broadcast (dVal + 3)
const localIp = `${aVal}.${bVal}.${cVal}.${dVal + 1}`;
const remoteIp = `${aVal}.${bVal}.${cVal}.${dVal + 2}`;
// Проверяем, свободны ли оба IP
if (!usedIps.has(localIp) && !usedIps.has(remoteIp)) {
return forRemote ? remoteIp : localIp;
}
}
}
}
}
}
// Если все IP заняты, возвращаем null
return null;
};
// === Генерация IP из пула ===
const generateIpFromPool = (cidr, usedIps, excludeIp = null, forRemote = false, pairedLocalIp = null) => {
// Парсим CIDR (например, "10.10.0.0/24")
const [network, prefixLength] = cidr.split('/');
if (!network || !prefixLength) {
return null;
}
const prefix = parseInt(prefixLength);
if (isNaN(prefix) || prefix < 0 || prefix > 32) {
return null;
}
const networkParts = network.split('.');
if (networkParts.length !== 4) {
return null;
}
const [a, b, c, d] = networkParts.map(Number);
if (networkParts.some(p => isNaN(Number(p)))) {
return null;
}
// Если подбираем Remote IP и есть Local IP, генерируем парный IP в той же сети /30
if (forRemote && pairedLocalIp) {
const localIpParts = pairedLocalIp.trim().split('.');
if (localIpParts.length === 4) {
// Проверяем, что Local IP входит в пул
if (!isIpInCidr(pairedLocalIp, cidr)) {
return null; // Local IP не в пуле, не можем сгенерировать Remote
}
const baseOctet = parseInt(localIpParts[3]);
const networkBase = Math.floor(baseOctet / 4) * 4;
const localOffset = baseOctet - networkBase;
// Для сетей /30 проверяем, что Local IP не является сетевым адресом или broadcast
// Local IP должен быть базовый + 1 или базовый + 2
if (prefix >= 30) {
const networkAddress = `${localIpParts[0]}.${localIpParts[1]}.${localIpParts[2]}.${networkBase}`;
const broadcastAddress = `${localIpParts[0]}.${localIpParts[1]}.${localIpParts[2]}.${networkBase + 3}`;
if (pairedLocalIp === networkAddress || pairedLocalIp === broadcastAddress) {
// Local IP является сетевым адресом или broadcast, не можем использовать
return null;
}
// Проверяем, что Local IP находится в допустимом диапазоне (базовый + 1 или базовый + 2)
if (localOffset !== 1 && localOffset !== 2) {
// Local IP не в допустимом диапазоне для /30
return null;
}
}
let remoteOffset;
if (localOffset === 1) {
remoteOffset = 2; // Если Local IP = базовый + 1, то Remote IP = базовый + 2
} else if (localOffset === 2) {
remoteOffset = 1; // Если Local IP = базовый + 2, то Remote IP = базовый + 1
} else {
// Если Local IP не в допустимом диапазоне, не можем сгенерировать парный Remote IP
return null;
}
const remoteOctet = networkBase + remoteOffset;
if (remoteOctet >= 0 && remoteOctet <= 255) {
const remoteIp = `${localIpParts[0]}.${localIpParts[1]}.${localIpParts[2]}.${remoteOctet}`;
// Проверяем, что Remote IP входит в диапазон пула и отличается от Local IP
// Также проверяем, что не используем сетевой адрес (networkBase) и broadcast (networkBase + 3)
if (isIpInCidr(remoteIp, cidr) && !usedIps.has(remoteIp) && remoteIp !== pairedLocalIp) {
// Для сетей /30 проверяем, что не используем сетевой адрес и broadcast
if (prefix >= 30) {
const networkAddress = `${localIpParts[0]}.${localIpParts[1]}.${localIpParts[2]}.${networkBase}`;
const broadcastAddress = `${localIpParts[0]}.${localIpParts[1]}.${localIpParts[2]}.${networkBase + 3}`;
if (remoteIp === networkAddress || remoteIp === broadcastAddress) {
// Remote IP является сетевым адресом или broadcast, не используем его
// Продолжаем поиск в общем цикле ниже
} else {
return remoteIp;
}
} else {
return remoteIp;
}
}
}
}
// Если не удалось сгенерировать парный Remote IP, продолжаем поиск в общем цикле ниже
// Не возвращаем null сразу, чтобы попробовать найти другую пару
}
// Вычисляем количество хостов в сети
const hostBits = 32 - prefix;
// Для /30 сетей используем шаг 4 (базовые адреса кратные 4)
const step = prefix >= 30 ? 4 : 1;
// Определяем диапазон для перебора в зависимости от префикса
if (prefix >= 24) {
// /24 - /32: меняем только последний октет
// Для сетей /30 базовый адрес должен быть кратен 4
let startOctet = d;
if (prefix >= 30) {
// Округляем вниз до ближайшего кратного 4
startOctet = Math.floor(d / 4) * 4;
}
const maxHosts = Math.pow(2, hostBits);
const endOctet = Math.min(d + maxHosts - 1, 255);
// Перебираем IP в диапазоне
for (let dVal = startOctet; dVal <= endOctet; dVal += step) {
if (prefix >= 30 && dVal % 4 !== 0) continue; // Для /30 только кратные 4
// Local IP = базовый + 1, Remote IP = базовый + 2
const localIp = `${a}.${b}.${c}.${dVal + 1}`;
const remoteIp = `${a}.${b}.${c}.${dVal + 2}`;
// Для сетей /30 проверяем границы
if (prefix >= 30) {
if (dVal % 4 !== 0) continue; // Базовый адрес должен быть кратен 4
if (dVal + 2 > 255) continue; // Проверяем, что remoteIp не превышает 255
}
// ВСЕГДА проверяем, что генерируемые IP не являются сетевыми или broadcast для любой сети /30
// Это важно даже если пул имеет префикс меньше /30
const localLastOctet = dVal + 1;
const remoteLastOctet = dVal + 2;
// Проверяем, что Local IP не является сетевым адресом (кратен 4) или broadcast (остаток 3 при делении на 4)
if (localLastOctet % 4 === 0 || localLastOctet % 4 === 3) continue;
// Проверяем, что Remote IP не является сетевым адресом (кратен 4) или broadcast (остаток 3 при делении на 4)
if (remoteLastOctet % 4 === 0 || remoteLastOctet % 4 === 3) continue;
// Проверяем, что IP входят в диапазон пула
if (!isIpInCidr(localIp, cidr)) continue;
if (!isIpInCidr(remoteIp, cidr)) continue;
// Проверяем, что IP отличаются друг от друга
if (localIp === remoteIp) continue;
// Для всех префиксов проверяем, что оба IP свободны (для туннелей нужны пары)
if (!usedIps.has(localIp) && !usedIps.has(remoteIp)) {
return forRemote ? remoteIp : localIp;
}
}
} else if (prefix >= 16) {
// /16 - /23: меняем третий и четвертый октеты
const thirdOctetRange = Math.pow(2, Math.max(0, 24 - prefix));
for (let cVal = c; cVal < c + thirdOctetRange && cVal <= 255; cVal++) {
let dStart = (cVal === c) ? d : 0;
// Для сетей /30 базовый адрес должен быть кратен 4
if (prefix >= 30) {
dStart = Math.floor(dStart / 4) * 4;
}
const dEnd = 255;
for (let dVal = dStart; dVal <= dEnd; dVal += step) {
if (prefix >= 30 && dVal % 4 !== 0) continue; // Для /30 только кратные 4
const localIp = `${a}.${b}.${cVal}.${dVal + 1}`; // Local IP = базовый + 1
const remoteIp = `${a}.${b}.${cVal}.${dVal + 2}`; // Remote IP = базовый + 2
// Для сетей /30 проверяем границы
if (prefix >= 30) {
if (dVal + 2 > 255) continue; // Проверяем, что remoteIp не превышает 255
}
// ВСЕГДА проверяем, что генерируемые IP не являются сетевыми или broadcast для любой сети /30
// Это важно даже если пул имеет префикс меньше /30
const localLastOctet = dVal + 1;
const remoteLastOctet = dVal + 2;
// Проверяем, что Local IP не является сетевым адресом (кратен 4) или broadcast (остаток 3 при делении на 4)
if (localLastOctet % 4 === 0 || localLastOctet % 4 === 3) continue;
// Проверяем, что Remote IP не является сетевым адресом (кратен 4) или broadcast (остаток 3 при делении на 4)
if (remoteLastOctet % 4 === 0 || remoteLastOctet % 4 === 3) continue;
// Проверяем, что IP входят в диапазон пула и отличаются
if (!isIpInCidr(localIp, cidr)) continue;
if (localIp === remoteIp) continue;
// Проверяем, что Remote IP тоже входит в пул
if (!isIpInCidr(remoteIp, cidr)) continue;
// Для всех префиксов проверяем, что оба IP свободны (для туннелей нужны пары)
if (!usedIps.has(localIp) && !usedIps.has(remoteIp)) {
return forRemote ? remoteIp : localIp;
}
}
}
} else {
// /0 - /15: слишком большой диапазон, используем упрощенную логику
return null;
}
return null;
};
// === Проверка, входит ли IP в CIDR ===
const isIpInCidr = (ip, cidr) => {
const [network, prefixLength] = cidr.split('/');
if (!network || !prefixLength) return false;
const prefix = parseInt(prefixLength);
if (isNaN(prefix)) return false;
const networkParts = network.split('.').map(Number);
const ipParts = ip.split('.').map(Number);
if (networkParts.length !== 4 || ipParts.length !== 4) return false;
// Вычисляем маску сети
const mask = (0xFFFFFFFF << (32 - prefix)) >>> 0;
// Преобразуем IP в число
const networkNum = (networkParts[0] << 24) | (networkParts[1] << 16) | (networkParts[2] << 8) | networkParts[3];
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
return (networkNum & mask) === (ipNum & mask);
};
// === Подбор IP для интерфейса ===
const handleSuggestLocalIp = () => {
const ipPoolId = editingInterface?.ipPoolId || null;
const freeIp = generateFreePrivateIp(editingInterface?.localIp, false, null, ipPoolId);
if (freeIp) {
setEditingInterface({ ...editingInterface, localIp: freeIp });
notify.success(`Подобран Local IP: ${freeIp}`);
} else {
notify.error('Не удалось найти свободный IP адрес');
}
};
const handleSuggestRemoteIp = () => {
// Если есть Local IP, генерируем парный Remote IP в той же сети /30
const ipPoolId = editingInterface?.ipPoolId || null;
const freeIp = generateFreePrivateIp(
editingInterface?.remoteIp,
true,
editingInterface?.localIp || null,
ipPoolId
);
if (freeIp) {
setEditingInterface({ ...editingInterface, remoteIp: freeIp });
notify.success(`Подобран Remote IP: ${freeIp}`);
} else {
notify.error('Не удалось найти свободный IP адрес');
}
};
// Функция для генерации PTR зоны с учетом замены
const generatePtrZone = (serverDns, iface) => {
if (!serverDns) return '—';
// Если указана замена PTR зоны, применяем её
if (iface?.ptrZoneReplaceFrom && iface?.ptrZoneReplaceTo) {
return serverDns.replace(iface.ptrZoneReplaceFrom, iface.ptrZoneReplaceTo);
}
return serverDns;
};
// === Получение реестра всех используемых IP адресов ===
const ipRegistry = useMemo(() => {
const registry = [];
// Собираем IP из интерфейсов - каждая запись это отдельное использование IP
(config.tunnelInterfaces || []).forEach(iface => {
if (iface.localIp && iface.localIp.trim()) {
const server = getServerInfo(iface.serverId);
registry.push({
ip: iface.localIp.trim(),
type: 'localIp',
interface: iface,
interfaceName: iface.name || iface.id,
server: server, // localIp привязан к serverId
serverName: server?.dns || server?.ip || iface.serverId || '—',
ptrZone: generatePtrZone(server?.dns, iface) // PTR зона с учетом замены
});
}
if (iface.remoteIp && iface.remoteIp.trim()) {
const server2 = getServerInfo(iface.serverId2);
registry.push({
ip: iface.remoteIp.trim(),
type: 'remoteIp',
interface: iface,
interfaceName: iface.name || iface.id,
server: server2, // remoteIp привязан к serverId2
serverName: server2?.dns || server2?.ip || iface.serverId2 || '—',
ptrZone: generatePtrZone(server2?.dns, iface) // PTR зона с учетом замены
});
}
});
// Проверяем конфликты (один IP используется в нескольких интерфейсах)
const ipUsageMap = {};
registry.forEach(item => {
if (!ipUsageMap[item.ip]) {
ipUsageMap[item.ip] = [];
}
ipUsageMap[item.ip].push(item);
});
// Добавляем флаг конфликта
return registry.map(item => ({
...item,
hasConflict: ipUsageMap[item.ip].length > 1
})).sort((a, b) => {
// Сначала конфликты, потом по IP, потом по типу
if (a.hasConflict !== b.hasConflict) {
return a.hasConflict ? -1 : 1;
}
if (a.ip !== b.ip) {
return a.ip.localeCompare(b.ip);
}
return a.type.localeCompare(b.type);
});
}, [config.tunnelInterfaces, servers]);
// === Фильтрация реестра IP ===
const filteredIpRegistry = useMemo(() => {
if (!ipRegistrySearch) return ipRegistry;
const term = ipRegistrySearch.toLowerCase();
return ipRegistry.filter(item =>
item.ip.toLowerCase().includes(term) ||
item.interfaceName.toLowerCase().includes(term) ||
item.serverName.toLowerCase().includes(term)
);
}, [ipRegistry, ipRegistrySearch]);
const handleSaveInterface = (ifaceData) => {
// Преобразуем имена интерфейсов в верхний регистр
const normalizedData = {
...ifaceData,
name: ifaceData.name ? ifaceData.name.toUpperCase() : '',
name2: ifaceData.name2 ? ifaceData.name2.toUpperCase() : '',
};
// Валидация: проверка пересечений IP адресов
const conflicts = checkInterfaceIpConflict(
normalizedData,
interfaceModalMode === 'edit' ? normalizedData.id : null
);
if (conflicts.length > 0) {
const conflictMessages = conflicts.map(c => {
const conflictName = c.conflictingInterface.name || c.conflictingInterface.id;
return `${c.type === 'localIp' ? 'Local IP' : 'Remote IP'} ${c.ip} уже используется в интерфейсе "${conflictName}"`;
});
notify.error(`Обнаружено пересечение IP адресов:\n${conflictMessages.join('\n')}`);
return;
}
if (interfaceModalMode === 'add') {
setConfig(prev => ({
...prev,
tunnelInterfaces: [...prev.tunnelInterfaces, normalizedData],
}));
notify.success('Интерфейс добавлен');
} else {
setConfig(prev => ({
...prev,
tunnelInterfaces: prev.tunnelInterfaces.map(i => i.id === normalizedData.id ? normalizedData : i),
}));
notify.success('Интерфейс обновлён');
}
setInterfaceModalOpen(false);
setEditingInterface(null);
};
const handleDeleteInterface = (iface) => {
setItemToDelete(iface);
setDeleteType('interface');
setDeleteModalOpen(true);
};
// === CRUD для IPSec Passwords ===
const handleAddIpsecPassword = () => {
setEditingIpsecPassword({ name: '', password: '', description: '' });
setIpsecPasswordModalMode('add');
setIpsecPasswordModalOpen(true);
};
const handleEditIpsecPassword = async (passwordId) => {
try {
const response = await api.get(`/ipsec-passwords/${passwordId}`);
setEditingIpsecPassword(response.data);
setIpsecPasswordModalMode('edit');
setIpsecPasswordModalOpen(true);
} catch (error) {
console.error('Error fetching IPSec password:', error);
notify.error('Не удалось загрузить пароль');
}
};
const handleSaveIpsecPassword = async () => {
if (!editingIpsecPassword.name || !editingIpsecPassword.password) {
notify.error('Имя и пароль обязательны');
return;
}
try {
let savedPasswordId = null;
if (ipsecPasswordModalMode === 'add') {
const response = await api.post('/ipsec-passwords', {
name: editingIpsecPassword.name,
password: editingIpsecPassword.password,
description: editingIpsecPassword.description || ''
});
notify.success('IPSec пароль создан');
// Получаем ID созданного пароля из ответа
savedPasswordId = response.data?.id || null;
} else {
await api.put(`/ipsec-passwords/${editingIpsecPassword.id}`, {
name: editingIpsecPassword.name,
password: editingIpsecPassword.password,
description: editingIpsecPassword.description || ''
});
notify.success('IPSec пароль обновлён');
savedPasswordId = editingIpsecPassword.id;
}
await fetchIpsecPasswords();
// Закрываем модал создания/редактирования пароля
setIpsecPasswordModalOpen(false);
setEditingIpsecPassword(null);
// Если модал списка паролей открыт, закрываем его
if (ipsecPasswordsListModalOpen) {
setIpsecPasswordsListModalOpen(false);
}
// Если создан новый пароль и открыт шаблон интерфейса, автоматически выбираем его
if (savedPasswordId && interfaceTemplateModalOpen && (templateType === 'IPSec' || templateType === 'GRE')) {
setTemplateIpsecPasswordId(savedPasswordId);
}
// Если создан новый пароль и открыт модал интерфейса с типом IPSec или GRE, автоматически выбираем его
if (savedPasswordId && interfaceModalOpen && (editingInterface?.type === 'IPSec' || editingInterface?.type === 'GRE')) {
setEditingInterface({ ...editingInterface, ipsecPasswordId: savedPasswordId });
}
} catch (error) {
console.error('Error saving IPSec password:', error);
notify.error('Не удалось сохранить пароль');
}
};
const handleDeleteIpsecPassword = async (passwordId) => {
try {
await api.delete(`/ipsec-passwords/${passwordId}`);
notify.success('IPSec пароль удалён');
await fetchIpsecPasswords();
} catch (error) {
console.error('Error deleting IPSec password:', error);
notify.error('Не удалось удалить пароль');
}
};
// === CRUD для IP Pools ===
const handleAddPool = () => {
setEditingPool(getEmptyIpPool());
setPoolModalMode('add');
setPoolModalOpen(true);
};
const handleEditPool = (pool) => {
setEditingPool({ ...pool });
setPoolModalMode('edit');
setPoolModalOpen(true);
};
const handleSavePool = (poolData) => {
if (poolModalMode === 'add') {
setConfig(prev => ({
...prev,
ipPools: [...prev.ipPools, poolData],
}));
notify.success('IP пул добавлен');
} else {
setConfig(prev => ({
...prev,
ipPools: prev.ipPools.map(p => p.id === poolData.id ? poolData : p),
}));
notify.success('IP пул обновлён');
}
setPoolModalOpen(false);
setEditingPool(null);
};
const handleDeletePool = (pool) => {
setItemToDelete(pool);
setDeleteType('pool');
setDeleteModalOpen(true);
};
// === Подтверждение удаления ===
const executeDelete = () => {
if (!itemToDelete) return;
if (deleteType === 'gateway') {
setConfig(prev => ({
...prev,
gateways: prev.gateways.filter(g => g.id !== itemToDelete.id),
}));
notify.success('Gateway удалён');
} else if (deleteType === 'interface') {
setConfig(prev => ({
...prev,
tunnelInterfaces: prev.tunnelInterfaces.filter(i => i.id !== itemToDelete.id),
}));
notify.success('Интерфейс удалён');
} else if (deleteType === 'pool') {
setConfig(prev => ({
...prev,
ipPools: prev.ipPools.filter(p => p.id !== itemToDelete.id),
}));
notify.success('IP пул удалён');
}
setDeleteModalOpen(false);
setItemToDelete(null);
setDeleteType('');
};
// === Copy to clipboard ===
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
};
// === Вспомогательная функция: построение блоков MikroTik для списка интерфейсов ===
const buildMikrotikInterfaceBlocks = async (interfacesWithBothServers) => {
if (!interfacesWithBothServers || interfacesWithBothServers.length === 0) {
return [];
}
// Загружаем пароли для интерфейсов с IPSec
const passwordIds = [...new Set(interfacesWithBothServers
.filter(i => i.ipsecPasswordId && i.ipsecPasswordId.trim() !== '')
.map(i => i.ipsecPasswordId.trim())
)];
const passwordMap = {};
for (const passwordId of passwordIds) {
try {
const response = await api.get(`/ipsec-passwords/${passwordId}`);
const password = response.data?.password || '';
if (password) {
passwordMap[passwordId] = password;
} else {
console.warn(`IPSec password ${passwordId} loaded but empty`);
}
} catch (error) {
console.error(`Error loading IPSec password ${passwordId}:`, error);
passwordMap[passwordId] = '';
}
}
// Группируем по серверам
const interfacesByServer = {};
interfacesWithBothServers.forEach(iface => {
const server1 = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
if (!server1 || !server2) return;
const server1Name = server1.dns || server1.ip || iface.serverId;
const server2Name = server2.dns || server2.ip || iface.serverId2;
// Имя интерфейса для первого сервера
const interfaceName1 = iface.name || `${iface.type}-tunnel`;
// Имя интерфейса для второго сервера - ВСЕГДА используем name2 если указано, иначе name
const interfaceName2 = iface.name2 || iface.name || `${iface.type}-tunnel`;
// Для первого сервера - используем localIp как local, remoteIp как remote
if (!interfacesByServer[server1Name]) {
interfacesByServer[server1Name] = {
serverInfo: server1,
interfaces: []
};
}
interfacesByServer[server1Name].interfaces.push({
interfaceName: interfaceName1,
localIp: iface.localIp,
remoteIp: iface.remoteIp,
type: iface.type,
server2Name,
server2Info: server2,
ipsecPasswordId: (iface.ipsecPasswordId && iface.ipsecPasswordId.trim() !== '') ? iface.ipsecPasswordId.trim() : null,
ipsecPassword: (iface.ipsecPasswordId && iface.ipsecPasswordId.trim() !== '') ? (passwordMap[iface.ipsecPasswordId.trim()] || null) : null,
mtu: iface.mtu || null
});
// Для второго сервера - МЕНЯЕМ МЕСТАМИ: remoteIp становится localIp, localIp становится remoteIp
// И ВСЕГДА используем name2 (или name если name2 не указано)
if (!interfacesByServer[server2Name]) {
interfacesByServer[server2Name] = {
serverInfo: server2,
interfaces: []
};
}
interfacesByServer[server2Name].interfaces.push({
interfaceName: interfaceName2, // Используем name2 для второго сервера
localIp: iface.remoteIp, // На втором сервере remoteIp первого становится localIp
remoteIp: iface.localIp, // На втором сервере localIp первого становится remoteIp
type: iface.type,
server2Name: server1Name,
server2Info: server1,
ipsecPasswordId: (iface.ipsecPasswordId && iface.ipsecPasswordId.trim() !== '') ? iface.ipsecPasswordId.trim() : null,
ipsecPassword: (iface.ipsecPasswordId && iface.ipsecPasswordId.trim() !== '') ? (passwordMap[iface.ipsecPasswordId.trim()] || null) : null,
mtu: iface.mtu || null
});
});
// Сортируем серверы по имени для консистентности
const sortedServers = Object.entries(interfacesByServer).sort(([a], [b]) => a.localeCompare(b));
const blocks = [];
sortedServers.forEach(([serverName, serverData]) => {
const server = serverData.serverInfo;
let code = `# Настройка IP адресов интерфейсов для сервера: ${serverName}\n`;
if (server.provider) {
code += `# Провайдер: ${server.provider}\n`;
}
if (server.country) {
code += `# Страна: ${server.country}\n`;
}
if (server.ip && server.ip !== serverName) {
code += `# IP: ${server.ip}\n`;
}
code += '\n';
// Проверяем, является ли это home роутером и есть ли GRE интерфейсы
const isHomeRouter = server.type === 'home';
const hasGreInterfaces = serverData.interfaces.some(iface => iface.type === 'GRE');
// Для home роутеров с GRE интерфейсами создаём interface list
if (isHomeRouter && hasGreInterfaces) {
code += `# Создание interface list для GRE туннелей (если не существует)\n`;
code += `:if ([/interface list find name=GREs] = "") do={ /interface list add name=GREs comment="GRE tunnels list" }\n`;
code += '\n';
}
serverData.interfaces.forEach((iface, ifaceIndex) => {
code += `# Интерфейс ${ifaceIndex + 1}: ${iface.interfaceName} (${iface.type})\n`;
code += `# Связь с сервером: ${iface.server2Name}\n`;
code += `# Local IP: ${iface.localIp}\n`;
code += `# Remote IP: ${iface.remoteIp}\n`;
// Для GRE туннелей - создаем туннель, опционально с IPSec через ipsec-secret
if (iface.type === 'GRE') {
// Используем DNS домен для remote-address, если он есть, иначе IP
const remoteAddress = iface.server2Info?.dns || iface.server2Info?.ip || iface.server2Name;
// Проверяем, является ли текущий сервер home роутером
const isHomeRouter = server.type === 'home';
// Если это home роутер, добавляем local-address с IP адресом этого сервера
const localAddressParam = isHomeRouter && server.ip ? ` local-address=${server.ip}` : '';
// Проверяем наличие пароля: ipsecPasswordId должен быть не пустым и пароль должен быть загружен
const hasIpsecPassword = iface.ipsecPasswordId &&
iface.ipsecPasswordId.trim() !== '' &&
iface.ipsecPassword &&
iface.ipsecPassword.trim() !== '';
if (hasIpsecPassword) {
// GRE туннель с IPSec через ipsec-secret (автоматическая настройка)
code += `# Создание GRE туннеля с IPSec (автоматическая настройка через ipsec-secret)\n`;
if (isHomeRouter) {
code += `# Home роутер: используется local-address=${server.ip}\n`;
}
const mtuParam = iface.mtu ? ` mtu=${iface.mtu}` : '';
code += `/interface gre add name="${iface.interfaceName}" remote-address=${remoteAddress}${localAddressParam} keepalive=10s allow-fast-path=no ipsec-secret="${iface.ipsecPassword}"${mtuParam}\n`;
// Для home роутеров добавляем интерфейс в interface list GREs
if (isHomeRouter) {
code += `/interface list member add list=GREs interface="${iface.interfaceName}" comment="GRE tunnel to ${iface.server2Name}"\n`;
}
code += '\n';
} else {
// GRE туннель без IPSec (голый GRE)
code += `# Создание GRE туннеля (без IPSec)\n`;
if (isHomeRouter) {
code += `# Home роутер: используется local-address=${server.ip}\n`;
}
const mtuParam = iface.mtu ? ` mtu=${iface.mtu}` : '';
code += `/interface gre add name="${iface.interfaceName}" remote-address=${remoteAddress}${localAddressParam} keepalive=10s allow-fast-path=no${mtuParam}\n`;
// Для home роутеров добавляем интерфейс в interface list GREs
if (isHomeRouter) {
code += `/interface list member add list=GREs interface="${iface.interfaceName}" comment="GRE tunnel to ${iface.server2Name}"\n`;
}
code += '\n';
}
}
// Добавляем IP адрес на интерфейс
code += `/ip address add address=${iface.localIp}/30 interface="${iface.interfaceName}" comment="Interface: ${iface.interfaceName} (${iface.type}) to ${iface.server2Name}"\n`;
code += '\n';
});
code += '# Проверка настроенных адресов на этом сервере:\n';
code += '# /ip address print\n';
// Для home роутеров добавляем проверку interface list
if (isHomeRouter && hasGreInterfaces) {
code += '\n# Проверка interface list GREs:\n';
code += '# /interface list print\n';
code += '# /interface list member print where list=GREs\n';
}
blocks.push({
type: 'interface-addresses',
serverName,
server,
code
});
});
return blocks;
};
// === Генерация кода MikroTik для настройки IP адресов интерфейсов (всех) ===
const generateMikrotikInterfaceAddresses = async () => {
const interfacesWithBothServers = (config.tunnelInterfaces || []).filter(i =>
i.serverId && i.serverId2 && i.localIp && i.remoteIp
);
return buildMikrotikInterfaceBlocks(interfacesWithBothServers);
};
// === Генерация кода MikroTik только для выбранных интерфейсов ===
const generateMikrotikInterfaceAddressesForSelection = async (selectedInterfaces) => {
const interfacesWithBothServers = (selectedInterfaces || []).filter(i =>
i.serverId && i.serverId2 && i.localIp && i.remoteIp
);
return buildMikrotikInterfaceBlocks(interfacesWithBothServers);
};
// === Генерация кода MikroTik для рекурсивных маршрутов ===
const generateMikrotikRecursiveRoutes = async () => {
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
const blocks = [];
// Если есть рекурсивные gateway, генерируем блоки для них
if (recursiveGateways.length > 0) {
// Группируем по серверам для удобства
const gatewaysByServer = {};
recursiveGateways.forEach(gw => {
const serverId = gw.serverId || '__unassigned__';
if (!gatewaysByServer[serverId]) {
gatewaysByServer[serverId] = [];
}
gatewaysByServer[serverId].push(gw);
});
// Генерируем блоки для рекурсивных маршрутов по серверам
Object.entries(gatewaysByServer).forEach(([serverId, gateways]) => {
const server = getServerInfo(serverId);
const serverName = server?.dns || server?.ip || serverId;
let code = `# Рекурсивные маршруты для сервера: ${serverName}\n`;
if (server?.provider) {
code += `# Провайдер: ${server.provider}\n`;
}
if (server?.country) {
code += `# Страна: ${server.country}\n`;
}
code += '\n';
code += '# Удаление существующих рекурсивных маршрутов (опционально)\n';
code += '/ip route remove [find comment~"Recursive"]\n\n';
let validRoutesCount = 0;
gateways.forEach(gw => {
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gw.parentGateways && Array.isArray(gw.parentGateways) && gw.parentGateways.length > 0
? gw.parentGateways
: (gw.parentGatewayId ? [{ id: gw.parentGatewayId, distance: undefined }] : []);
if (parentGatewaysList.length === 0) {
code += `# Gateway "${gw.ip || 'без IP'}" - родительские gateway/интерфейсы не указаны, пропущен\n`;
return;
}
if (!gw.ip) {
code += `# Gateway без IP адреса - пропущен\n`;
return;
}
// Обрабатываем каждый родительский gateway
parentGatewaysList.forEach((parentRef, parentIndex) => {
const parent = getParentGateway(parentRef.id);
if (!parent) {
code += `# Gateway "${gw.ip}" - родительский gateway/интерфейс "${parentRef.id}" не найден, пропущен\n`;
return;
}
// Определяем IP родителя в зависимости от типа
const parentIp = parent.parentType === 'interface' ? parent.remoteIp : parent.ip;
if (!parentIp) {
const parentType = parent.parentType === 'interface' ? 'интерфейс' : 'gateway';
code += `# Gateway "${gw.ip}" - родительский ${parentType} не имеет IP, пропущен\n`;
return;
}
validRoutesCount++;
// Создаём маршрут для рекурсивного gateway
const parentLabel = parent.parentType === 'interface'
? `интерфейс ${parent.name || parent.interfaceType || 'interface'} (${parent.remoteIp})`
: `gateway ${parent.ip}`;
const distanceSuffix = parentRef.distance !== undefined && parentRef.distance !== null ? ` (distance: ${parentRef.distance})` : '';
code += `# Рекурсивный gateway: ${gw.ip}${distanceSuffix}\n`;
code += `# Родительский: ${parentLabel}\n`;
// Формируем gateway: для интерфейсов добавляем имя через %, для gateway - только IP
const gatewayValue = parent.parentType === 'interface'
? `${parentIp}%${parent.name || parent.interfaceType || 'interface'}`
: parentIp;
// Создаём маршрут к IP рекурсивного gateway через родительский
const comment = gw.description
? `Recursive: ${gw.description} -> ${parentIp}${distanceSuffix}`
: `Recursive: ${gw.ip} -> ${parentIp}${distanceSuffix}`;
// Формируем команду с distance, если указан
const distanceParam = parentRef.distance !== undefined && parentRef.distance !== null ? ` distance=${parentRef.distance}` : '';
code += `/ip route add dst-address=${gw.ip}/32 gateway=${gatewayValue}${distanceParam} comment="${comment}"\n`;
// Если у рекурсивного gateway есть описание с указанием на default route
if (gw.description && (gw.description.toLowerCase().includes('default') || gw.description.toLowerCase().includes('0.0.0.0'))) {
code += `# Дополнительный default route через рекурсивный gateway${distanceSuffix}\n`;
const defaultComment = gw.description ? `Recursive: ${gw.description} (default)${distanceSuffix}` : `Recursive: ${gw.ip} (default)${distanceSuffix}`;
code += `/ip route add dst-address=0.0.0.0/0 gateway=${gatewayValue}${distanceParam} comment="${defaultComment}"\n`;
}
code += '\n';
});
});
code += '# Проверка созданных маршрутов:\n';
code += '# /ip route print where comment~"Recursive"\n';
code += `# Всего создано маршрутов: ${validRoutesCount}\n`;
if (validRoutesCount > 0) {
blocks.push({
type: 'recursive-routes',
serverName,
server,
code
});
}
});
}
// Генерируем блоки для IP адресов интерфейсов
const interfaceBlocks = await generateMikrotikInterfaceAddresses();
if (interfaceBlocks && interfaceBlocks.length > 0) {
blocks.push(...interfaceBlocks);
}
return blocks;
};
// === Обработчик кнопки "Код для MikroTik" ===
const handleGenerateMikrotikCode = async () => {
setLoading(true);
try {
const code = await generateMikrotikRecursiveRoutes();
setGeneratedMikrotikCode(code);
setMikrotikCodeModalOpen(true);
} catch (error) {
console.error('Error generating MikroTik code:', error);
notify.error('Ошибка при генерации кода MikroTik');
} finally {
setLoading(false);
}
};
// === Генерация кода MikroTik для всех интерфейсов сервера ===
const handleGenerateMikrotikCodeForServer = async (serverId, type) => {
if (!serverId) {
notify.error('Сервер не указан');
return;
}
setLoading(true);
try {
if (type === 'interface') {
// Получаем все интерфейсы для этого сервера (где сервер участвует как первый или второй)
// Фильтруем только те, где оба сервера указаны и есть IP адреса
const serverInterfaces = (config.tunnelInterfaces || []).filter(iface =>
(iface.serverId === serverId || iface.serverId2 === serverId) &&
iface.serverId && iface.serverId2 && iface.localIp && iface.remoteIp
);
if (serverInterfaces.length === 0) {
notify.error('Нет интерфейсов с полной конфигурацией для этого сервера');
setLoading(false);
return;
}
const blocks = await generateMikrotikInterfaceAddressesForSelection(serverInterfaces);
if (!blocks || blocks.length === 0) {
notify.error('Нет данных для генерации кода MikroTik для этого сервера');
setLoading(false);
return;
}
// Фильтруем блоки, чтобы показать только те, что относятся к данному серверу
const server = getServerInfo(serverId);
const serverName = server?.dns || server?.ip || serverId;
const filteredBlocks = blocks.filter(block =>
block.serverName === serverName || block.server?.id === serverId
);
if (filteredBlocks.length === 0) {
notify.error('Не удалось сгенерировать код для этого сервера');
setLoading(false);
return;
}
setGeneratedMikrotikCode(filteredBlocks);
setMikrotikCodeModalOpen(true);
} else {
notify.error('Генерация кода для gateways по серверу пока не реализована');
}
} catch (error) {
console.error('Error generating MikroTik code for server:', error);
notify.error('Ошибка при генерации кода MikroTik для этого сервера');
} finally {
setLoading(false);
}
};
// === Генерация кода MikroTik только для одного интерфейса ===
const handleGenerateMikrotikCodeForInterface = async (iface) => {
if (!iface) {
notify.error('Интерфейс не найден');
return;
}
setLoading(true);
try {
const blocks = await generateMikrotikInterfaceAddressesForSelection([iface]);
if (!blocks || blocks.length === 0) {
notify.error('Нет данных для генерации кода MikroTik для этого интерфейса');
return;
}
setGeneratedMikrotikCode(blocks);
setMikrotikCodeModalOpen(true);
} catch (error) {
console.error('Error generating MikroTik code for interface:', error);
notify.error('Ошибка при генерации кода MikroTik для этого интерфейса');
} finally {
setLoading(false);
}
};
// === Генерация кода MikroTik только для одного gateway ===
const handleGenerateMikrotikCodeForGateway = async (gateway) => {
if (!gateway) {
notify.error('Gateway не найден');
return;
}
setLoading(true);
try {
const blocks = [];
const server = getServerInfo(gateway.serverId);
const serverName = server?.dns || server?.ip || gateway.serverId || '__unassigned__';
if (gateway.type === 'recursive') {
// Генерация кода для рекурсивного gateway
if (!gateway.ip) {
notify.error('Gateway без IP адреса - невозможно сгенерировать код');
setLoading(false);
return;
}
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gateway.parentGateways && Array.isArray(gateway.parentGateways) && gateway.parentGateways.length > 0
? gateway.parentGateways
: (gateway.parentGatewayId ? [{ id: gateway.parentGatewayId, distance: undefined }] : []);
if (parentGatewaysList.length === 0) {
notify.error('Родительские gateway/интерфейсы не указаны');
setLoading(false);
return;
}
let code = `# Рекурсивный маршрут для gateway: ${gateway.ip}\n`;
if (server?.provider) {
code += `# Провайдер: ${server.provider}\n`;
}
if (server?.country) {
code += `# Страна: ${server.country}\n`;
}
code += '\n';
let validRoutesCount = 0;
parentGatewaysList.forEach((parentRef, parentIndex) => {
const parent = getParentGateway(parentRef.id);
if (!parent) {
code += `# Родительский gateway/интерфейс "${parentRef.id}" не найден, пропущен\n`;
return;
}
// Определяем IP родителя в зависимости от типа
const parentIp = parent.parentType === 'interface' ? parent.remoteIp : parent.ip;
if (!parentIp) {
const parentType = parent.parentType === 'interface' ? 'интерфейс' : 'gateway';
code += `# Родительский ${parentType} не имеет IP, пропущен\n`;
return;
}
validRoutesCount++;
// Создаём маршрут для рекурсивного gateway
const parentLabel = parent.parentType === 'interface'
? `интерфейс ${parent.name || parent.interfaceType || 'interface'} (${parent.remoteIp})`
: `gateway ${parent.ip}`;
const distanceSuffix = parentRef.distance !== undefined && parentRef.distance !== null ? ` (distance: ${parentRef.distance})` : '';
code += `# Рекурсивный gateway: ${gateway.ip}${distanceSuffix}\n`;
code += `# Родительский: ${parentLabel}\n`;
// Формируем gateway: для интерфейсов добавляем имя через %, для gateway - только IP
const gatewayValue = parent.parentType === 'interface'
? `${parentIp}%${parent.name || parent.interfaceType || 'interface'}`
: parentIp;
// Создаём маршрут к IP рекурсивного gateway через родительский
const comment = gateway.description
? `Recursive: ${gateway.description} -> ${parentIp}${distanceSuffix}`
: `Recursive: ${gateway.ip} -> ${parentIp}${distanceSuffix}`;
// Формируем команду с distance, если указан
const distanceParam = parentRef.distance !== undefined && parentRef.distance !== null ? ` distance=${parentRef.distance}` : '';
code += `/ip route add dst-address=${gateway.ip}/32 gateway=${gatewayValue}${distanceParam} comment="${comment}"\n`;
// Если у рекурсивного gateway есть описание с указанием на default route
if (gateway.description && (gateway.description.toLowerCase().includes('default') || gateway.description.toLowerCase().includes('0.0.0.0'))) {
code += `# Дополнительный default route через рекурсивный gateway${distanceSuffix}\n`;
const defaultComment = gateway.description ? `Recursive: ${gateway.description} (default)${distanceSuffix}` : `Recursive: ${gateway.ip} (default)${distanceSuffix}`;
code += `/ip route add dst-address=0.0.0.0/0 gateway=${gatewayValue}${distanceParam} comment="${defaultComment}"\n`;
}
code += '\n';
});
code += '# Проверка созданных маршрутов:\n';
code += '# /ip route print where comment~"Recursive"\n';
code += `# Всего создано маршрутов: ${validRoutesCount}\n`;
if (validRoutesCount > 0) {
blocks.push({
type: 'recursive-routes',
serverName,
server,
code
});
}
} else if (gateway.type === 'direct') {
// Генерация кода для прямого gateway
if (!gateway.ip) {
notify.error('Gateway без IP адреса - невозможно сгенерировать код');
setLoading(false);
return;
}
let code = `# Прямой маршрут для gateway: ${gateway.ip}\n`;
if (server?.provider) {
code += `# Провайдер: ${server.provider}\n`;
}
if (server?.country) {
code += `# Страна: ${server.country}\n`;
}
code += '\n';
const comment = gateway.description
? `Direct: ${gateway.description}`
: `Direct: ${gateway.ip}`;
code += `/ip route add dst-address=0.0.0.0/0 gateway=${gateway.ip} comment="${comment}"\n`;
code += '\n';
code += '# Проверка созданного маршрута:\n';
code += '# /ip route print where comment~"Direct"\n';
blocks.push({
type: 'direct-route',
serverName,
server,
code
});
} else {
notify.error('Неизвестный тип gateway');
setLoading(false);
return;
}
if (blocks.length === 0) {
notify.error('Нет данных для генерации кода MikroTik для этого gateway');
return;
}
setGeneratedMikrotikCode(blocks);
setMikrotikCodeModalOpen(true);
} catch (error) {
console.error('Error generating MikroTik code for gateway:', error);
notify.error('Ошибка при генерации кода MikroTik для этого gateway');
} finally {
setLoading(false);
}
};
// === Копирование кода MikroTik ===
const handleCopyMikrotikCode = async (codeToCopy) => {
if (!codeToCopy) {
notify.error('Нет кода для копирования');
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(codeToCopy);
notify.success('Код скопирован в буфер обмена!');
} else {
// Fallback для старых браузеров
const textArea = document.createElement('textarea');
textArea.value = codeToCopy;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
notify.success('Код скопирован в буфер обмена!');
} catch (err) {
notify.error('Не удалось скопировать код');
}
document.body.removeChild(textArea);
}
} catch (err) {
console.error('Error copying to clipboard:', err);
notify.error('Не удалось скопировать код');
}
};
// === Копирование отдельного блока кода ===
const handleCopyBlock = async (code) => {
await handleCopyMikrotikCode(code);
};
// === Копирование всего кода ===
const handleCopyAllMikrotikCode = async () => {
const fullCode = Array.isArray(generatedMikrotikCode) && generatedMikrotikCode.length > 0
? generatedMikrotikCode.map(block => block.code).join('\n\n')
: '';
await handleCopyMikrotikCode(fullCode);
};
// === Группировка блоков по серверам ===
const groupedMikrotikCode = useMemo(() => {
if (!Array.isArray(generatedMikrotikCode) || generatedMikrotikCode.length === 0) {
return {};
}
const grouped = {};
generatedMikrotikCode.forEach(block => {
const serverName = block.serverName || '__unassigned__';
if (!grouped[serverName]) {
grouped[serverName] = {
server: block.server,
serverName: serverName,
blocks: []
};
}
grouped[serverName].blocks.push(block);
});
return grouped;
}, [generatedMikrotikCode]);
// === Получение названия типа блока ===
const getBlockTypeLabel = (type) => {
switch (type) {
case 'recursive-routes':
return 'Рекурсивные маршруты';
case 'interface-addresses':
return 'IP адреса интерфейсов';
default:
return 'Конфигурация';
}
};
// === Получение цвета провайдера ===
const getProviderColor = (provider) => {
if (!provider) return 'secondary';
// Ищем точное совпадение
const exact = GATEWAY_PROVIDERS.find(gp =>
gp.value === provider ||
gp.label.toLowerCase() === provider.toLowerCase()
);
if (exact) return exact.color;
// Проверяем частичное совпадение
const partial = GATEWAY_PROVIDERS.find(gp =>
provider.toLowerCase().includes(gp.value.toLowerCase()) ||
provider.toLowerCase().includes(gp.label.toLowerCase())
);
return partial?.color || 'secondary';
};
const getInterfaceTypeColor = (type) => {
const t = INTERFACE_TYPES.find(it => it.value === type);
return t?.color || 'secondary';
};
// === Рендер категории с аккордеоном ===
const renderCategoryAccordion = (category, itemsByServer, type) => {
const categoryItems = Object.entries(itemsByServer);
if (categoryItems.length === 0) return null;
const isExpanded = expandedCategories.has(category);
const totalItems = categoryItems.reduce((sum, [, items]) => sum + items.length, 0);
return (
<div key={category} className="card mb-3">
{/* Заголовок категории (кликабельный) */}
<div
className="card-header py-2"
style={{
backgroundColor: 'rgba(32, 107, 196, 0.05)',
cursor: 'pointer',
userSelect: 'none'
}}
onClick={() => toggleCategory(category)}
>
<div className="d-flex align-items-center">
{/* Иконка сворачивания */}
{isExpanded ? (
<IconChevronUp size={20} className="me-2 text-muted" />
) : (
<IconChevronDown size={20} className="me-2 text-muted" />
)}
<h3 className="card-title mb-0 me-2">{getCategoryLabel(category)}</h3>
<span className="badge bg-blue-lt text-blue ms-auto">
{categoryItems.length} {categoryItems.length === 1 ? 'сервер' : categoryItems.length < 5 ? 'сервера' : 'серверов'} {totalItems} {type === 'gateway' ? (totalItems === 1 ? 'gateway' : 'gateways') : (totalItems === 1 ? 'интерфейс' : 'интерфейсов')}
</span>
</div>
</div>
{/* Контент (сворачиваемый) */}
{isExpanded && (
<div className="card-body p-3">
{categoryItems
.sort(([a], [b]) => {
// Сортируем: сначала серверы с информацией, потом без привязки
if (a === '__unassigned__') return 1;
if (b === '__unassigned__') return -1;
const serverA = getServerInfo(a);
const serverB = getServerInfo(b);
const nameA = serverA?.dns || serverA?.ip || a;
const nameB = serverB?.dns || serverB?.ip || b;
return nameA.localeCompare(nameB);
})
.map(([serverId, items]) =>
renderServerGroup(serverId, items, type)
)}
</div>
)}
</div>
);
};
// === Рендер карточки сервера с его элементами ===
const renderServerGroup = (serverId, items, type) => {
const server = getServerInfo(serverId);
const isUnassigned = serverId === '__unassigned__';
return (
<div key={serverId} className="card mb-3">
{/* Server header - улучшенная структура с четкой иерархией */}
<div
className="card-header py-3 px-4"
style={{
backgroundColor: 'rgba(32, 107, 196, 0.06)',
borderBottom: '1px solid rgba(32, 107, 196, 0.15)',
borderLeft: '4px solid rgba(32, 107, 196, 0.3)'
}}
>
<div className="d-flex align-items-start flex-wrap gap-3">
{/* Левая часть: основная информация */}
<div className="d-flex align-items-start flex-grow-1" style={{ minWidth: 0 }}>
{isUnassigned ? (
<>
<span className="avatar avatar-lg bg-secondary-lt me-3 flex-shrink-0">
<IconServer size={24} className="text-muted" />
</span>
<div className="flex-grow-1">
<h3 className="mb-0 fw-bold text-muted">Без привязки к серверу</h3>
</div>
</>
) : (
<>
<span className="avatar avatar-lg bg-primary-lt me-3 flex-shrink-0">
<IconServer size={24} className="text-primary" />
</span>
<div className="flex-grow-1" style={{ minWidth: 0 }}>
{/* Основная строка: название сервера */}
<div className="d-flex align-items-center mb-2 flex-wrap gap-2">
<h3 className="mb-0 fw-bold" style={{ fontSize: '1.25rem', lineHeight: '1.3' }}>
{server?.dns ? server.dns.split('.')[0] : (server?.name || serverId)}
</h3>
{server?.country && (
<span className="badge bg-blue-lt text-blue d-flex align-items-center flex-shrink-0" style={{ fontSize: '0.875rem', padding: '4px 10px' }}>
<span className="me-1" style={{ fontSize: '1rem' }}>{countryToFlag(server.country)}</span>
{server.country}
</span>
)}
{server?.provider && (
<span className={`badge bg-${getProviderColor(server.provider)}-lt text-${getProviderColor(server.provider)} flex-shrink-0`} style={{ fontSize: '0.875rem', padding: '4px 10px' }}>
{server.provider}
</span>
)}
</div>
{/* Дополнительная информация: DNS и IP */}
<div className="d-flex align-items-center flex-wrap gap-3" style={{ fontSize: '0.875rem' }}>
{server?.dns && (
<div className="d-flex align-items-center flex-shrink-0">
<span className="text-muted me-1">DNS:</span>
<code className="text-dark" style={{ fontSize: '0.875rem', backgroundColor: 'rgba(0, 0, 0, 0.04)', padding: '2px 6px', borderRadius: '4px', wordBreak: 'break-all' }}>
{server.dns}
</code>
</div>
)}
{server?.ip && (
<div className="d-flex align-items-center flex-shrink-0">
<span className="text-muted me-1">IP:</span>
<code className="text-dark" style={{ fontSize: '0.875rem', backgroundColor: 'rgba(0, 0, 0, 0.04)', padding: '2px 6px', borderRadius: '4px' }}>
{server.ip}
</code>
</div>
)}
</div>
</div>
</>
)}
</div>
{/* Правая часть: счетчик и действия */}
<div className="d-flex align-items-center gap-2 flex-shrink-0">
<span className="badge bg-primary text-white" style={{ fontSize: '0.875rem', padding: '6px 12px', fontWeight: '600', whiteSpace: 'nowrap' }}>
{items.length} {type === 'gateway' ? (items.length === 1 ? 'gateway' : 'gateways') : (items.length === 1 ? 'интерфейс' : 'интерфейсов')}
</span>
{type === 'interface' && !isUnassigned && items.length > 0 && (
<button
className="btn btn-primary btn-sm flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
handleGenerateMikrotikCodeForServer(serverId, type);
}}
title="Получить код MikroTik для всех интерфейсов этого сервера"
style={{ whiteSpace: 'nowrap' }}
>
<IconCode size={16} className="me-1" />
Код для MikroTik
</button>
)}
</div>
</div>
</div>
{/* Items - улучшенное расположение */}
<div className="card-body p-4">
{items.length === 0 ? (
<div className="text-center text-muted py-3">
<small>Нет элементов для отображения</small>
</div>
) : (
<div className="row row-cards g-3">
{items.map(item => (
<div key={item.id} className="col-12 col-lg-6">
{type === 'gateway' ? renderGatewayCard(item) :
type === 'interface' ? renderInterfaceCard(item, serverId) :
type === 'pool' ? renderPoolCard(item) : null}
</div>
))}
</div>
)}
</div>
</div>
);
};
// === Получение родительского gateway или интерфейса ===
const getParentGateway = (parentId, templateGatewaysList = null) => {
if (!parentId) return null;
// Если передан список templateGateways, сначала ищем там
if (templateGatewaysList) {
const templateGateway = templateGatewaysList.find(g => g.id === parentId);
if (templateGateway) {
return {
parentType: 'gateway',
...templateGateway,
ip: templateGateway.ip
};
}
}
// Сначала ищем в gateway
const gateway = config.gateways?.find(g => g.id === parentId);
if (gateway) {
return {
parentType: 'gateway', // Используем parentType чтобы не конфликтовать с type интерфейса
...gateway,
ip: gateway.ip // Явно указываем IP для gateway
};
}
// Если не найден, ищем в интерфейсах
const iface = config.tunnelInterfaces?.find(i => i.id === parentId);
if (iface) {
return {
parentType: 'interface', // Используем parentType чтобы не конфликтовать с type интерфейса
...iface,
remoteIp: iface.remoteIp, // Явно указываем remoteIp для интерфейса
localIp: iface.localIp,
name: iface.name,
interfaceType: iface.type // Сохраняем тип интерфейса (GRE, WireGuard и т.д.) отдельно
};
}
// Если не найден ни gateway, ни интерфейс - логируем для отладки
console.warn('Parent gateway/interface not found:', parentId, {
availableGateways: config.gateways?.map(g => g.id),
availableInterfaces: config.tunnelInterfaces?.map(i => i.id),
templateGateways: templateGatewaysList?.map(g => g.id)
});
return null;
};
// === Рендер карточки IP Pool ===
const renderPoolCard = (pool) => {
const server = getServerInfo(pool.serverId);
const serverCountry = server?.country || '';
// Определяем цвет индикатора (зеленый для IP пулов)
const indicatorColor = 'green';
const IndicatorIcon = IconDatabase;
return (
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
<div className="card-body p-0">
<div className="d-flex align-items-stretch">
{/* Левая часть: тип индикатора */}
<div
className={`d-flex align-items-center justify-content-center px-3 bg-${indicatorColor}-lt`}
style={{
minWidth: '100px',
width: '100px',
borderTopLeftRadius: '11px',
borderBottomLeftRadius: '11px'
}}
>
<div className="text-center">
<div className="mb-1">
<IndicatorIcon size={20} className={`text-${indicatorColor}`} />
</div>
<div className={`fw-bold text-${indicatorColor}`} style={{ fontSize: '0.65rem', letterSpacing: '0.5px' }}>
Pool
</div>
</div>
</div>
{/* Центральная часть: информация */}
<div className="flex-grow-1 py-2 px-3" style={{ minWidth: 0, flex: '1 1 auto', overflow: 'hidden' }}>
{/* Верхняя строка: флаг, название */}
<div className="d-flex align-items-center mb-1">
<div className="d-flex align-items-center" style={{ flex: '0 0 auto', minWidth: 0 }}>
{serverCountry && (
<span className="me-2" style={{ fontSize: '1.25rem', flexShrink: 0 }}>{countryToFlag(serverCountry)}</span>
)}
<div>
<span className="fw-semibold">{pool.name || '—'}</span>
</div>
</div>
</div>
{/* CIDR под названием */}
{pool.cidr && (
<div className="mb-1">
<code className="text-muted" style={{ fontSize: '0.875rem' }}>
{pool.cidr}
</code>
</div>
)}
{/* Нижняя строка: DNS сервера */}
<div className="d-flex align-items-center gap-2 flex-wrap">
{server && (
<code className="text-muted" style={{ fontSize: '0.8rem', wordBreak: 'break-all', overflowWrap: 'break-word' }}>
{server.dns || server.ip || pool.serverId}
</code>
)}
{!server && pool.serverId && (
<code className="text-muted" style={{ fontSize: '0.8rem' }}>
{pool.serverId}
</code>
)}
</div>
</div>
{/* Правая часть: действия - компактные иконки */}
<div className="d-flex align-items-center gap-1 px-2 border-start" style={{ flexShrink: 0 }}>
<Tooltip content="Изменить" position="top">
<button
className="btn btn-outline-secondary btn-icon btn-sm"
onClick={() => handleEditPool(pool)}
aria-label="Изменить"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => handleDeletePool(pool)}
aria-label="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
);
};
// === Рендер карточки Gateway ===
const renderGatewayCard = (gateway) => {
const server = getServerInfo(gateway.serverId);
const provider = server?.provider || '';
const serverCountry = server?.country || '';
const gatewayCountry = gateway.country || '';
const displayCountry = gatewayCountry || serverCountry;
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gateway.type === 'recursive'
? (gateway.parentGateways && Array.isArray(gateway.parentGateways) && gateway.parentGateways.length > 0
? gateway.parentGateways
: (gateway.parentGatewayId ? [{ id: gateway.parentGatewayId, distance: undefined }] : []))
: [];
// Получаем информацию о первом родительском gateway для отображения
const firstParent = parentGatewaysList.length > 0 ? getParentGateway(parentGatewaysList[0].id) : null;
// Определяем тип индикатора (как в ServerCard)
const indicatorType = gateway.type === 'recursive' ? 'Рекурсивный' : 'Прямой';
const indicatorColor = gateway.type === 'recursive' ? 'red' : 'blue';
const IndicatorIcon = gateway.type === 'recursive' ? IconArrowsRightLeft : IconRouter;
// Формируем текст для зеленого блока
let displayText = '';
if (gateway.type === 'direct') {
// Для прямого gateway: 0.0.0.0/0 -> IP адрес
displayText = gateway.ip ? `0.0.0.0/0 → ${gateway.ip}` : '';
} else if (gateway.type === 'recursive' && firstParent) {
// Для рекурсивного: IP адрес -> IP адрес первого родительского gateway
const parentIp = firstParent.parentType === 'interface'
? firstParent.remoteIp
: firstParent.ip;
displayText = gateway.ip && parentIp ? `${gateway.ip}${parentIp}` : '';
// Если несколько родительских gateway, добавляем информацию
if (parentGatewaysList.length > 1) {
displayText += ` (+${parentGatewaysList.length - 1})`;
}
}
return (
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
<div className="card-body p-0">
<div className="d-flex align-items-stretch">
{/* Левая часть: тип индикатора (как в ServerCard) */}
<div
className={`d-flex align-items-center justify-content-center px-3 bg-${indicatorColor}-lt`}
style={{
minWidth: '100px',
width: '100px',
borderTopLeftRadius: '11px',
borderBottomLeftRadius: '11px'
}}
>
<div className="text-center">
<div className="mb-1">
<IndicatorIcon size={20} className={`text-${indicatorColor}`} />
</div>
<div className={`fw-bold text-${indicatorColor}`} style={{ fontSize: '0.65rem', letterSpacing: '0.5px' }}>
{indicatorType}
</div>
</div>
</div>
{/* Центральная часть: информация */}
<div className="flex-grow-1 py-2 px-3" style={{ minWidth: 0, flex: '1 1 auto', overflow: 'hidden' }}>
{/* Верхняя строка: флаг, название */}
<div className="d-flex align-items-center mb-1">
<div className="d-flex align-items-center flex-grow-1" style={{ minWidth: 0 }}>
{displayCountry && (
<span className="me-2 flex-shrink-0" style={{ fontSize: '1.25rem' }}>{countryToFlag(displayCountry)}</span>
)}
<div className="flex-grow-1" style={{ minWidth: 0, overflow: 'hidden' }}>
<span className="fw-semibold d-block text-truncate" style={{ maxWidth: '100%' }}>{gateway.description || gateway.ip || '—'}</span>
</div>
</div>
</div>
{/* Маршрут ip->ip сразу под названием */}
{displayText && (
<div className="mb-1">
<span className="badge bg-success-lt text-success d-inline-block" style={{ fontSize: '0.875rem', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayText}
</span>
</div>
)}
{/* Нижняя строка: DNS */}
<div className="d-flex align-items-center gap-2 flex-wrap">
{server && (
<code className="text-muted d-block text-truncate" style={{ fontSize: '0.8rem', wordBreak: 'break-all', overflowWrap: 'break-word', maxWidth: '100%' }}>
{server.dns || server.ip || gateway.serverId}
</code>
)}
</div>
</div>
{/* Правая часть: действия - компактные иконки */}
<div className="d-flex align-items-center gap-1 px-2 border-start" style={{ flexShrink: 0 }}>
<Tooltip content="Получить код MikroTik" position="top">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => handleGenerateMikrotikCodeForGateway(gateway)}
aria-label="Получить код MikroTik"
>
<IconCode size={16} />
</button>
</Tooltip>
<Tooltip content="Изменить" position="top">
<button
className="btn btn-outline-secondary btn-icon btn-sm"
onClick={() => handleEditGateway(gateway)}
aria-label="Изменить"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => handleDeleteGateway(gateway)}
aria-label="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
);
};
// === Рендер карточки Interface ===
const renderInterfaceCard = (iface, currentServerId) => {
const server = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
// Определяем, для какого сервера отображается карточка
const isForServer2 = currentServerId && iface.serverId2 && (() => {
const currentServer = getServerInfo(currentServerId);
const targetServer2 = getServerInfo(iface.serverId2);
if (!currentServer || !targetServer2) return false;
return (
currentServer.id === targetServer2.id ||
currentServer.ip === targetServer2.ip ||
currentServer.dns === targetServer2.dns ||
currentServerId === iface.serverId2 ||
currentServerId === targetServer2.id ||
currentServerId === targetServer2.ip ||
currentServerId === targetServer2.dns
);
})();
// Имя интерфейса зависит от того, для какого сервера отображается
const interfaceName = isForServer2
? (iface.name2 || iface.name || '—')
: (iface.name || '—');
// IP адреса также зависят от того, для какого сервера отображается
const displayLocalIp = isForServer2 ? (iface.remoteIp || '—') : (iface.localIp || '—');
const displayRemoteIp = isForServer2 ? (iface.localIp || '—') : (iface.remoteIp || '—');
// Определяем сервер для отображения
const displayServer = isForServer2 ? server2 : server;
const displayServerName = displayServer?.dns || displayServer?.ip || (isForServer2 ? iface.serverId2 : iface.serverId);
const displayCountry = displayServer?.country || '';
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
return (
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
<div className="card-body p-0">
<div className="d-flex align-items-stretch">
{/* Левая часть: тип интерфейса (как в ServerCard) */}
<div
className={`d-flex align-items-center justify-content-center px-3 bg-${interfaceTypeColor}-lt`}
style={{
minWidth: '100px',
width: '100px',
borderTopLeftRadius: '11px',
borderBottomLeftRadius: '11px'
}}
>
<div className="text-center">
<div className="mb-1">
<IconRouter size={20} className={`text-${interfaceTypeColor}`} />
</div>
<div className={`fw-bold text-${interfaceTypeColor}`} style={{ fontSize: '0.65rem', letterSpacing: '0.5px' }}>
{iface.type}
</div>
</div>
</div>
{/* Центральная часть: информация */}
<div className="flex-grow-1 py-2 px-3" style={{ minWidth: 0, flex: '1 1 auto', overflow: 'hidden' }}>
{/* Верхняя строка: флаг, название */}
<div className="d-flex align-items-center mb-1">
<div className="d-flex align-items-center flex-grow-1" style={{ minWidth: 0 }}>
{displayCountry && (
<span className="me-2 flex-shrink-0" style={{ fontSize: '1.25rem' }}>{countryToFlag(displayCountry)}</span>
)}
<div className="flex-grow-1" style={{ minWidth: 0, overflow: 'hidden' }}>
<span className="fw-semibold d-block text-truncate" style={{ maxWidth: '100%' }}>{interfaceName}</span>
</div>
</div>
</div>
{/* Маршрут между Local IP и Remote IP сразу под названием */}
{displayLocalIp !== '—' && displayRemoteIp !== '—' && (
<div className="mb-1">
<span className="badge bg-success-lt text-success d-inline-block" style={{ fontSize: '0.875rem', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayLocalIp} {displayRemoteIp}
</span>
</div>
)}
{/* Нижняя строка: DNS */}
<div className="d-flex align-items-center gap-2 flex-wrap">
{displayServerName && (
<code className="text-muted d-block text-truncate" style={{ fontSize: '0.8rem', wordBreak: 'break-all', overflowWrap: 'break-word', maxWidth: '100%' }}>
{displayServerName}
</code>
)}
</div>
</div>
{/* Правая часть: действия - компактные иконки */}
<div className="d-flex align-items-center gap-1 px-2 border-start" style={{ flexShrink: 0 }}>
<Tooltip content="Получить код MikroTik" position="top">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => handleGenerateMikrotikCodeForInterface(iface)}
aria-label="Получить код MikroTik"
>
<IconCode size={16} />
</button>
</Tooltip>
<Tooltip content="Изменить" position="top">
<button
className="btn btn-outline-secondary btn-icon btn-sm"
onClick={() => handleEditInterface(iface)}
aria-label="Изменить"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => handleDeleteInterface(iface)}
aria-label="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
);
};
return (
<div className="network-config-manager">
{/* Page Header */}
<div className="page-header d-print-none mb-4">
<div className="row align-items-center">
<div className="col">
<div className="page-pretitle">Управление</div>
<h2 className="page-title d-flex align-items-center">
<IconNetwork className="me-2" size={28} />
Сетевые настройки
</h2>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
{/* Tabs as button group */}
<div className="btn-group me-2" role="group">
<button
className={`btn ${activeTab === 'gateways' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('gateways')}
>
<IconWorld size={16} className="me-1 d-none d-sm-inline" />
Gateways
<span className={`badge ms-1 ${activeTab === 'gateways' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{config.gateways?.length || 0}
</span>
</button>
<button
className={`btn ${activeTab === 'interfaces' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('interfaces')}
>
<IconRouter size={16} className="me-1 d-none d-sm-inline" />
Интерфейсы
<span className={`badge ms-1 ${activeTab === 'interfaces' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{config.tunnelInterfaces?.length || 0}
</span>
</button>
<button
className={`btn ${activeTab === 'pools' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('pools')}
>
<IconDatabase size={16} className="me-1 d-none d-sm-inline" />
IP пулы
<span className={`badge ms-1 ${activeTab === 'pools' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{config.ipPools?.length || 0}
</span>
</button>
<button
className={`btn ${activeTab === 'ip-registry' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('ip-registry')}
>
<IconList size={16} className="me-1 d-none d-sm-inline" />
Реестр IP
<span className={`badge ms-1 ${activeTab === 'ip-registry' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{ipRegistry.length}
</span>
{ipRegistry.some(item => item.hasConflict) && (
<span className="badge bg-danger ms-1" title="Обнаружены конфликты IP адресов">
!
</span>
)}
</button>
</div>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => { fetchConfig(); fetchServers(); }}
disabled={loading}
>
<IconRefresh size={16} className={loading ? 'spin' : ''} />
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={handleGenerateMikrotikCode}
disabled={loading || (
(config.gateways || []).filter(gw => gw.type === 'recursive').length === 0 &&
(config.tunnelInterfaces || []).filter(i => i.serverId && i.serverId2 && i.localIp && i.remoteIp).length === 0
)}
title="Генерировать код MikroTik для рекурсивных маршрутов и IP адресов интерфейсов"
>
<IconCode size={16} className="me-1" />
Код для MikroTik
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
disabled={saving}
>
<IconDatabase size={16} className={`me-1 ${saving ? 'spin' : ''}`} />
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Toolbar */}
<div className="card mb-3">
<div className="card-body py-2">
<div className="row g-2 align-items-center">
{/* Search */}
<div className="col-12 col-md-4 col-lg-3">
<div className="input-icon">
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<span className="input-icon-addon" style={{ pointerEvents: 'auto', cursor: 'pointer' }} onClick={() => setSearchTerm('')}>
<IconX size={16} />
</span>
)}
</div>
</div>
{/* Server filter */}
{(activeTab === 'gateways' || activeTab === 'interfaces' || activeTab === 'pools') && uniqueServersInConfig.length > 0 && (
<div className="col-6 col-md-3 col-lg-2">
<select
className="form-select"
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
>
<option value="">Все серверы</option>
{uniqueServersInConfig.map(s => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
)}
{/* Provider filter for gateways - берем уникальные провайдеры из серверов */}
{activeTab === 'gateways' && (
<div className="col-6 col-md-3 col-lg-2">
<select
className="form-select"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
>
<option value="">Все провайдеры</option>
{Array.from(new Set(servers.map(s => s.provider).filter(Boolean))).map(provider => (
<option key={provider} value={provider}>{provider}</option>
))}
</select>
</div>
)}
{/* Type filter for interfaces */}
{activeTab === 'interfaces' && (
<div className="col-6 col-md-3 col-lg-2">
<select
className="form-select"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="">Все типы</option>
{INTERFACE_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
)}
{/* Reset filters */}
{hasActiveFilters && (
<div className="col-auto">
<button className="btn btn-ghost-secondary btn-sm" onClick={resetFilters}>
<IconX size={14} className="me-1" />
Сбросить
</button>
</div>
)}
<div className="col-auto ms-auto d-flex gap-2">
{/* View mode toggle */}
{(activeTab === 'gateways' || activeTab === 'interfaces') && (
<div className="btn-group btn-group-sm" role="group">
<button
className={`btn ${viewMode === 'cards' ? 'btn-secondary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('cards')}
title="Карточки"
>
<IconLayoutGrid size={16} />
</button>
<button
className={`btn ${viewMode === 'table' ? 'btn-secondary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('table')}
title="Таблица"
>
<IconList size={16} />
</button>
</div>
)}
{/* Add button */}
{activeTab === 'gateways' && (
<>
<button className="btn btn-primary btn-sm" onClick={handleAddGateway}>
<IconPlus size={16} className="me-1" />
Gateway
</button>
<button
className="btn btn-outline-primary btn-sm"
onClick={handleOpenGatewayTemplate}
title="Создать gateways из шаблона"
>
<IconSparkles size={16} className="me-1" />
Шаблон
</button>
</>
)}
{activeTab === 'interfaces' && (
<>
<button className="btn btn-primary btn-sm" onClick={handleAddInterface}>
<IconPlus size={16} className="me-1" />
Интерфейс
</button>
<button
className="btn btn-outline-primary btn-sm"
onClick={() => setInterfaceTemplateModalOpen(true)}
title="Создать интерфейс из шаблона"
>
<IconSparkles size={16} className="me-1" />
Шаблон
</button>
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setIpsecPasswordsListModalOpen(true)}
title="Управление IPSec паролями"
>
<IconLock size={16} className="me-1" />
IPSec пароли
</button>
</>
)}
{activeTab === 'pools' && (
<button className="btn btn-primary btn-sm" onClick={handleAddPool}>
<IconPlus size={16} className="me-1" />
IP пул
</button>
)}
</div>
</div>
</div>
</div>
{/* Content */}
{loading ? (
<div className="card">
<div className="card-body text-center py-5">
<div className="spinner-border text-primary" role="status"></div>
<div className="text-muted mt-2">Загрузка...</div>
</div>
</div>
) : (
<>
{/* Gateways Tab */}
{activeTab === 'gateways' && (
<>
{filteredGateways.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-3">
<span className="avatar avatar-xl bg-primary-lt">
<IconWorld size={40} className="text-primary" />
</span>
</div>
<h3>Gateways не найдены</h3>
<p className="text-muted">
{hasActiveFilters
? 'Попробуйте изменить параметры фильтрации'
: 'Добавьте gateway для использования в конфигурациях серверов'
}
</p>
{!hasActiveFilters && (
<button className="btn btn-primary" onClick={handleAddGateway}>
<IconPlus size={16} className="me-1" />
Добавить Gateway
</button>
)}
</div>
</div>
) : viewMode === 'cards' ? (
// Grouped cards view with category accordions
<div>
{['home', 'jumphost', 'exit', 'other'].map(category =>
renderCategoryAccordion(category, gatewaysByCategory[category], 'gateway')
)}
</div>
) : (
// Table view
<div className="card">
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>IP адрес</th>
<th>Тип</th>
<th>Провайдер</th>
<th>Сервер</th>
<th>Страна</th>
<th className="text-end" style={{ width: 100 }}>Действия</th>
</tr>
</thead>
<tbody>
{filteredGateways.map(gateway => {
const server = getServerInfo(gateway.serverId);
const provider = server?.provider || '';
const serverCountry = server?.country || '';
const gatewayCountry = gateway.country || '';
const displayCountry = gatewayCountry || serverCountry;
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gateway.type === 'recursive'
? (gateway.parentGateways && Array.isArray(gateway.parentGateways) && gateway.parentGateways.length > 0
? gateway.parentGateways
: (gateway.parentGatewayId ? [{ id: gateway.parentGatewayId, distance: undefined }] : []))
: [];
const firstParent = parentGatewaysList.length > 0 ? getParentGateway(parentGatewaysList[0].id) : null;
return (
<tr key={gateway.id}>
<td>
<div className="d-flex align-items-center">
<span className={`avatar avatar-xs bg-${gatewayType.color}-lt me-2`}>
<IconCircleFilled size={8} className={`text-${gatewayType.color}`} />
</span>
<div className="flex-fill">
<div className="d-flex align-items-center gap-1">
<code className="fw-medium">{gateway.ip || '—'}</code>
{gateway.ip && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(gateway.ip)}
style={{ width: 20, height: 20 }}
title="Копировать IP"
>
<IconCopy size={12} />
</button>
)}
</div>
{gateway.description && (
<div className="text-muted small">{gateway.description}</div>
)}
</div>
</div>
</td>
<td>
<span className={`badge bg-${gatewayType.color}-lt text-${gatewayType.color}`}>
{gatewayType.label}
</span>
{firstParent && (
<div className="text-muted small mt-1">
{firstParent.parentType === 'interface'
? `Интерфейс ${firstParent.name || firstParent.interfaceType || 'interface'} (${firstParent.remoteIp || '—'})`
: firstParent.ip
? `Gateway ${firstParent.ip}`
: 'Gateway (не указан IP)'
}
{parentGatewaysList.length > 1 && (
<span className="ms-1">(+{parentGatewaysList.length - 1})</span>
)}
</div>
)}
</td>
<td>
{provider ? (
<span className={`badge bg-${getProviderColor(provider)}-lt text-${getProviderColor(provider)}`}>
{provider}
</span>
) : '—'}
</td>
<td>
{server ? (
<div className="d-flex align-items-center">
<IconServer size={14} className="text-muted me-1" />
<span>{server.dns || server.ip}</span>
</div>
) : gateway.serverId ? (
<span className="text-muted">{gateway.serverId}</span>
) : (
<span className="text-muted"></span>
)}
</td>
<td>
{displayCountry ? (
<div>
<span>{countryToFlag(displayCountry)} {displayCountry}</span>
{gatewayCountry && serverCountry && gatewayCountry !== serverCountry && (
<div className="text-muted small" style={{ fontSize: '0.7rem' }}>
сервер: {serverCountry}
</div>
)}
</div>
) : '—'}
</td>
<td className="text-end">
<div className="btn-list gap-1 mb-0 justify-content-end">
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => handleGenerateMikrotikCodeForGateway(gateway)}
title="Код для MikroTik (только для этого gateway)"
>
<IconCode size={16} />
</button>
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => handleEditGateway(gateway)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-ghost-danger btn-icon btn-sm"
onClick={() => handleDeleteGateway(gateway)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* Interfaces Tab */}
{activeTab === 'interfaces' && (
<>
{filteredInterfaces.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-3">
<span className="avatar avatar-xl bg-blue-lt">
<IconRouter size={40} className="text-blue" />
</span>
</div>
<h3>Интерфейсы не найдены</h3>
<p className="text-muted">
{hasActiveFilters
? 'Попробуйте изменить параметры фильтрации'
: 'Добавьте туннельные интерфейсы для серверов'
}
</p>
{!hasActiveFilters && (
<button className="btn btn-primary" onClick={handleAddInterface}>
<IconPlus size={16} className="me-1" />
Добавить интерфейс
</button>
)}
</div>
</div>
) : viewMode === 'cards' ? (
// Grouped cards view with category accordions
<div>
{['home', 'jumphost', 'exit', 'other'].map(category =>
renderCategoryAccordion(category, interfacesByCategory[category], 'interface')
)}
</div>
) : (
// Table view
<div className="card">
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>Имя</th>
<th>Тип</th>
<th>Local IP</th>
<th>Remote IP</th>
<th>Серверы</th>
<th className="text-end" style={{ width: 100 }}>Действия</th>
</tr>
</thead>
<tbody>
{filteredInterfaces.map(iface => {
const server = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
return (
<tr key={iface.id}>
<td><strong>{iface.name || '—'}</strong></td>
<td>
<span className={`badge bg-${getInterfaceTypeColor(iface.type)}-lt text-${getInterfaceTypeColor(iface.type)}`}>
{iface.type}
</span>
</td>
<td>
<code className="d-flex align-items-center gap-1">
{iface.localIp || '—'}
{iface.localIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.localIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</code>
</td>
<td>
<code className="d-flex align-items-center gap-1">
{iface.remoteIp || '—'}
{iface.remoteIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.remoteIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</code>
</td>
<td>
<div>
{server ? (
<div className="d-flex align-items-center">
<IconServer size={14} className="text-muted me-1" />
<span>{server.dns || server.ip}</span>
</div>
) : iface.serverId ? (
<span className="text-muted">{iface.serverId}</span>
) : (
<span className="text-muted"></span>
)}
{server2 && (
<div className="d-flex align-items-center mt-1">
<IconServer size={14} className="text-muted me-1" />
<span className="text-muted small">{server2.dns || server2.ip}</span>
</div>
)}
</div>
</td>
<td className="text-end">
<div className="btn-list gap-1 mb-0 justify-content-end">
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => handleGenerateMikrotikCodeForInterface(iface)}
title="Код для MikroTik (только для этого интерфейса)"
>
<IconCode size={16} />
</button>
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => handleEditInterface(iface)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-ghost-danger btn-icon btn-sm"
onClick={() => handleDeleteInterface(iface)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* IP Pools Tab */}
{activeTab === 'pools' && (
<>
{filteredPools.length === 0 ? (
<div className="empty">
<div className="empty-img">
<IconDatabase size={48} />
</div>
<p className="empty-title">IP пулы не найдены</p>
<p className="empty-subtitle text-muted">
{hasActiveFilters
? 'Попробуйте изменить параметры фильтрации'
: 'Добавьте пулы IP-адресов для организации сетей'
}
</p>
{!hasActiveFilters && (
<div className="empty-action">
<button className="btn btn-primary" onClick={handleAddPool}>
<IconPlus size={16} className="me-2" />
Добавить IP пул
</button>
</div>
)}
</div>
) : (
<>
{/* Группировка по серверам */}
{(serverFilter || filteredPools.some(p => p.serverId)) && (() => {
const poolsByServer = {};
filteredPools.forEach(pool => {
const key = pool.serverId || '__unassigned__';
if (!poolsByServer[key]) {
poolsByServer[key] = [];
}
poolsByServer[key].push(pool);
});
return Object.entries(poolsByServer)
.sort(([a], [b]) => {
if (a === '__unassigned__') return 1;
if (b === '__unassigned__') return -1;
return a.localeCompare(b);
})
.map(([serverId, pools]) => renderServerGroup(serverId, pools, 'pool'));
})()}
{/* Если нет фильтра по серверу и все пулы без сервера - показываем без группировки */}
{!serverFilter && !filteredPools.some(p => p.serverId) && (
<div className="row row-cards">
{filteredPools.map(pool => (
<div key={pool.id} className="col-12 col-md-6 col-lg-6">
{renderPoolCard(pool)}
</div>
))}
</div>
)}
</>
)}
</>
)}
{/* IP Registry Tab */}
{activeTab === 'ip-registry' && (
<>
<div className="mb-3">
<div className="input-group">
<span className="input-group-text">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по IP адресу или имени интерфейса..."
value={ipRegistrySearch}
onChange={(e) => setIpRegistrySearch(e.target.value)}
/>
</div>
</div>
{filteredIpRegistry.length === 0 ? (
<div className="empty">
<div className="empty-img">
<IconList size={48} />
</div>
<p className="empty-title">Реестр IP адресов пуст</p>
<p className="empty-subtitle text-muted">
{ipRegistrySearch
? 'Попробуйте изменить поисковый запрос'
: 'Добавьте интерфейсы, чтобы увидеть используемые IP адреса'}
</p>
</div>
) : (
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>IP адрес</th>
<th>Тип</th>
<th>Сервер</th>
<th>PTR зона</th>
<th>Интерфейс</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{filteredIpRegistry.map((item, idx) => (
<tr key={`${item.ip}-${item.type}-${item.interface.id}-${idx}`} className={item.hasConflict ? 'table-danger' : ''}>
<td>
<div className="d-flex align-items-center">
<code className="fw-bold">{item.ip}</code>
{item.hasConflict && (
<span className="badge bg-danger ms-2" title="Конфликт: IP используется в нескольких интерфейсах">
Конфликт
</span>
)}
</div>
</td>
<td>
<span className={`badge ${item.type === 'localIp' ? 'bg-info-lt text-info' : 'bg-success-lt text-success'}`}>
{item.type === 'localIp' ? 'Local' : 'Remote'}
</span>
</td>
<td>
<div className="d-flex align-items-center">
<IconServer size={14} className="text-muted me-1" />
<span>{item.serverName}</span>
</div>
</td>
<td>
<code className="text-muted">{item.ptrZone}</code>
</td>
<td>
<div className="d-flex align-items-center gap-2">
<span>{item.interfaceName}</span>
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => handleEditInterface(item.interface)}
title="Редактировать интерфейс"
>
<IconEdit size={14} />
</button>
</div>
</td>
<td className="text-end">
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => {
navigator.clipboard.writeText(item.ip).then(() => {
notify.success('IP адрес скопирован');
}).catch(() => {
notify.error('Не удалось скопировать IP адрес');
});
}}
title="Копировать IP адрес"
>
<IconCopy size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</>
)}
{/* Gateway Modal */}
<FormModal
show={gatewayModalOpen}
onClose={() => { setGatewayModalOpen(false); setEditingGateway(null); }}
onSubmit={() => editingGateway && handleSaveGateway(editingGateway)}
title={gatewayModalMode === 'add' ? 'Добавить Gateway' : 'Редактировать Gateway'}
submitLabel={gatewayModalMode === 'add' ? 'Добавить' : 'Сохранить'}
submitIcon={gatewayModalMode === 'add' ? IconPlus : IconEdit}
size="lg"
>
{editingGateway && (
<div className="row g-3">
<div className="col-12">
<label className="form-label required">Сервер</label>
<ServerAutocompleteInput
value={editingGateway.serverId}
onChange={(val) => setEditingGateway({ ...editingGateway, serverId: val })}
servers={servers}
placeholder="Выберите сервер..."
/>
<div className="form-text">
Провайдер будет автоматически взят из данных сервера
</div>
</div>
<div className="col-md-6">
<FormField
label="IP адрес"
name="ip"
value={editingGateway.ip}
onChange={(val) => setEditingGateway({ ...editingGateway, ip: val })}
placeholder="94.142.140.1"
required
/>
<div className="form-text">
IP адрес является главным идентификатором gateway
</div>
</div>
<div className="col-md-6">
<FormField
label="Название (комментарий)"
name="description"
value={editingGateway.description}
onChange={(val) => setEditingGateway({ ...editingGateway, description: val })}
placeholder="SWE-IHOR или Основной выход через Cloudflare"
/>
<div className="form-text">
Название gateway для удобства (отображается в фильтрах)
</div>
</div>
<div className="col-md-6">
<FormField
label="Тип gateway"
name="type"
type="select"
value={editingGateway.type}
onChange={(val) => setEditingGateway({ ...editingGateway, type: val, parentGateways: val === 'direct' ? [] : (editingGateway.parentGateways || []) })}
options={GATEWAY_TYPES.map(t => ({ value: t.value, label: `${t.label} - ${t.description}` }))}
required
/>
</div>
<div className="col-md-6">
<FormField
label="Страна"
name="country"
value={editingGateway.country}
onChange={(val) => setEditingGateway({ ...editingGateway, country: val.toUpperCase().slice(0, 2) })}
placeholder="SWE"
/>
<div className="form-text">2-буквенный код страны (ISO 3166-1 alpha-2)</div>
</div>
{editingGateway.type === 'recursive' && (
<div className="col-12">
<label className="form-label">Родительские gateway или интерфейсы</label>
<div className="d-flex flex-column gap-2">
{(editingGateway.parentGateways || []).map((parent, index) => {
const parentInfo = getParentGateway(parent.id);
return (
<div key={index} className="card card-sm border">
<div className="card-body p-2">
<div className="row g-2 align-items-center">
<div className="col-md-8">
<GatewayAutocompleteInput
value={parent.id}
onChange={(val) => {
const newParents = [...(editingGateway.parentGateways || [])];
newParents[index] = { ...newParents[index], id: val };
setEditingGateway({ ...editingGateway, parentGateways: newParents });
}}
gateways={config.gateways}
interfaces={config.tunnelInterfaces}
serverId={editingGateway.serverId}
excludeGatewayId={editingGateway.id}
placeholder="Выберите родительский gateway или интерфейс..."
/>
</div>
<div className="col-md-3">
<FormField
label="Distance"
name="distance"
type="number"
value={parent.distance || ''}
onChange={(val) => {
const newParents = [...(editingGateway.parentGateways || [])];
newParents[index] = { ...newParents[index], distance: val ? parseInt(val) : undefined };
setEditingGateway({ ...editingGateway, parentGateways: newParents });
}}
placeholder="1"
/>
</div>
<div className="col-md-1 d-flex align-items-end">
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => {
const newParents = (editingGateway.parentGateways || []).filter((_, i) => i !== index);
setEditingGateway({ ...editingGateway, parentGateways: newParents });
}}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</div>
{parentInfo && (
<div className="mt-2 small text-muted">
{parentInfo.parentType === 'interface'
? `Интерфейс ${parentInfo.name || parentInfo.interfaceType || 'interface'} (${parentInfo.remoteIp || '—'})`
: `Gateway ${parentInfo.ip || '—'}`
}
</div>
)}
</div>
</div>
);
})}
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => {
const newParents = [...(editingGateway.parentGateways || []), { id: '', distance: undefined }];
setEditingGateway({ ...editingGateway, parentGateways: newParents });
}}
>
<IconPlus size={16} className="me-1" />
Добавить родительский gateway
</button>
</div>
<div className="form-text">
Рекурсивный gateway может ссылаться на несколько прямых gateway или интерфейсов. Distance определяет приоритет маршрута (меньше = выше приоритет).
</div>
</div>
)}
{editingGateway.serverId && (() => {
const server = getServerInfo(editingGateway.serverId);
return server ? (
<div className="col-12">
<div className="alert alert-info mb-0">
<div className="d-flex align-items-center">
<IconServer size={16} className="me-2" />
<div>
<strong>{server.dns || server.ip}</strong>
{server.provider && <span className="ms-2"> {server.provider}</span>}
{server.country && <span className="ms-2"> {countryToFlag(server.country)} {server.country}</span>}
</div>
</div>
</div>
</div>
) : null;
})()}
</div>
)}
</FormModal>
{/* Interface Modal */}
<FormModal
show={interfaceModalOpen}
onClose={() => { setInterfaceModalOpen(false); setEditingInterface(null); }}
onSubmit={() => editingInterface && handleSaveInterface(editingInterface)}
title={interfaceModalMode === 'add' ? 'Добавить интерфейс' : 'Редактировать интерфейс'}
submitLabel={interfaceModalMode === 'add' ? 'Добавить' : 'Сохранить'}
submitIcon={interfaceModalMode === 'add' ? IconPlus : IconEdit}
size="lg"
>
{editingInterface && (
<div className="row g-3">
<div className="col-12">
<label className="form-label">Сервер</label>
<ServerAutocompleteInput
value={editingInterface.serverId}
onChange={(val) => setEditingInterface({ ...editingInterface, serverId: val })}
servers={servers}
placeholder="Выберите сервер..."
/>
<div className="form-text">Интерфейс будет привязан к выбранному серверу</div>
</div>
<div className="col-md-6">
<FormField
label="Имя интерфейса (сервер 1)"
name="name"
value={editingInterface.name}
onChange={(val) => setEditingInterface({ ...editingInterface, name: val })}
placeholder="gre-tunnel1"
required
/>
</div>
{editingInterface.serverId2 && (
<div className="col-md-6">
<FormField
label="Имя интерфейса (сервер 2)"
name="name2"
value={editingInterface.name2 || ''}
onChange={(val) => setEditingInterface({ ...editingInterface, name2: val })}
placeholder="gre-tunnel2"
/>
<div className="form-text">Если не указано, будет использовано имя с сервера 1</div>
</div>
)}
<div className="col-md-6">
<FormField
label="Тип"
name="type"
type="select"
value={editingInterface.type}
onChange={(val) => setEditingInterface({ ...editingInterface, type: val, ipsecPasswordId: (val !== 'IPSec' && val !== 'GRE') ? '' : editingInterface.ipsecPasswordId })}
options={INTERFACE_TYPES}
/>
</div>
{(editingInterface.type === 'IPSec' || editingInterface.type === 'GRE') && (
<div className="col-md-6">
<label className="form-label">IPSec пароль</label>
<div className="input-group">
<select
className="form-select"
value={editingInterface.ipsecPasswordId || ''}
onChange={(e) => setEditingInterface({ ...editingInterface, ipsecPasswordId: e.target.value })}
>
<option value="">Не выбран</option>
{ipsecPasswords.map(pwd => (
<option key={pwd.id} value={pwd.id}>
{pwd.name} {pwd.description ? `(${pwd.description})` : ''}
</option>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={() => {
setIpsecPasswordModalMode('add');
setEditingIpsecPassword({ name: '', password: '', description: '' });
setIpsecPasswordModalOpen(true);
}}
title="Создать новый IPSec пароль"
>
<IconPlus size={16} />
</button>
</div>
<div className="form-text">
Выберите сохраненный IPSec пароль или создайте новый (опционально для GRE)
</div>
</div>
)}
<div className="col-md-6">
<label className="form-label">IP пул</label>
<select
className="form-select"
value={editingInterface.ipPoolId || ''}
onChange={(e) => setEditingInterface({ ...editingInterface, ipPoolId: e.target.value || '' })}
>
<option value="">Автоматический подбор (RFC 1918)</option>
{config.ipPools?.map(pool => (
<option key={pool.id} value={pool.id}>
{pool.name} ({pool.cidr}) {pool.description ? `- ${pool.description}` : ''}
</option>
))}
</select>
<div className="form-text">
Выберите IP пул для подбора адресов или оставьте автоматический подбор
</div>
</div>
<div className="col-md-6">
<FormField
label="MTU"
name="mtu"
value={editingInterface.mtu || ''}
onChange={(val) => setEditingInterface({ ...editingInterface, mtu: val })}
placeholder="1500"
type="number"
/>
<div className="form-text">Максимальный размер передаваемого пакета (опционально)</div>
</div>
<div className="col-md-3">
<label className="form-label">Local IP</label>
<div className="input-group">
<input
type="text"
className="form-control"
value={editingInterface.localIp || ''}
onChange={(e) => setEditingInterface({ ...editingInterface, localIp: e.target.value })}
placeholder="10.10.0.1"
/>
<button
type="button"
className="btn btn-outline-primary"
onClick={handleSuggestLocalIp}
title="Подобрать свободный IP из приватного диапазона"
>
<IconWand size={16} />
</button>
</div>
{editingInterface.localIp && (() => {
const conflicts = checkInterfaceIpConflict(
{ localIp: editingInterface.localIp },
interfaceModalMode === 'edit' ? editingInterface.id : null
).filter(c => c.type === 'localIp');
if (conflicts.length > 0) {
return (
<div className="form-text text-danger">
⚠️ Этот IP уже используется в интерфейсе "{conflicts[0].conflictingInterface.name || conflicts[0].conflictingInterface.id}"
</div>
);
}
return null;
})()}
</div>
<div className="col-md-3">
<label className="form-label">Remote IP</label>
<div className="input-group">
<input
type="text"
className="form-control"
value={editingInterface.remoteIp || ''}
onChange={(e) => setEditingInterface({ ...editingInterface, remoteIp: e.target.value })}
placeholder="10.10.0.2"
/>
<button
type="button"
className="btn btn-outline-primary"
onClick={handleSuggestRemoteIp}
title="Подобрать свободный IP из приватного диапазона (в той же сети /30 что и Local IP)"
>
<IconWand size={16} />
</button>
</div>
{editingInterface.remoteIp && (() => {
const conflicts = checkInterfaceIpConflict(
{ remoteIp: editingInterface.remoteIp },
interfaceModalMode === 'edit' ? editingInterface.id : null
).filter(c => c.type === 'remoteIp');
if (conflicts.length > 0) {
return (
<div className="form-text text-danger">
⚠️ Этот IP уже используется в интерфейсе "{conflicts[0].conflictingInterface.name || conflicts[0].conflictingInterface.id}"
</div>
);
}
return null;
})()}
</div>
<div className="col-12">
<label className="form-label">Второй сервер (опционально)</label>
<ServerAutocompleteInput
value={editingInterface.serverId2 || ''}
onChange={(val) => setEditingInterface({ ...editingInterface, serverId2: val })}
servers={servers.filter(s => s.id !== editingInterface.serverId && s.ip !== editingInterface.serverId)}
placeholder="Выберите второй сервер (для понимания связи между серверами)..."
/>
<div className="form-text">
Укажите второй сервер, если интерфейс связывает два сервера
</div>
</div>
{editingInterface.serverId && (() => {
const server = getServerInfo(editingInterface.serverId);
return server ? (
<div className="col-12">
<div className="card card-sm border">
<div className="card-body p-2">
<div className="d-flex align-items-center">
<IconServer size={16} className="text-muted me-2" />
<span className="fw-medium text-muted me-2">Сервер 1:</span>
<span className="me-2">{server.dns || server.ip}</span>
{server.provider && (
<>
<span className="text-muted me-2"></span>
<span className="me-2">{server.provider}</span>
</>
)}
{server.country && (
<>
<span className="text-muted me-2"></span>
<span>{countryToFlag(server.country)}</span>
<span className="ms-1">{server.country}</span>
</>
)}
</div>
</div>
</div>
</div>
) : null;
})()}
{editingInterface.serverId2 && (() => {
const server2 = getServerInfo(editingInterface.serverId2);
return server2 ? (
<div className="col-12">
<div className="card card-sm border">
<div className="card-body p-2">
<div className="d-flex align-items-center">
<IconServer size={16} className="text-muted me-2" />
<span className="fw-medium text-muted me-2">Сервер 2:</span>
<span className="me-2">{server2.dns || server2.ip}</span>
{server2.provider && (
<>
<span className="text-muted me-2"></span>
<span className="me-2">{server2.provider}</span>
</>
)}
{server2.country && (
<>
<span className="text-muted me-2"></span>
<span>{countryToFlag(server2.country)}</span>
<span className="ms-1">{server2.country}</span>
</>
)}
</div>
</div>
</div>
</div>
) : null;
})()}
{/* Поля для замены PTR зоны */}
<div className="col-12">
<hr />
<h6 className="mb-3">Настройка PTR зоны</h6>
<div className="row g-3">
<div className="col-md-6">
<FormField
label="Заменить в DNS домене"
name="ptrZoneReplaceFrom"
value={editingInterface.ptrZoneReplaceFrom || ''}
onChange={(val) => setEditingInterface({ ...editingInterface, ptrZoneReplaceFrom: val })}
placeholder="rt.shx"
/>
<div className="form-text">
Часть DNS домена, которую нужно заменить (например, "rt.shx")
</div>
</div>
<div className="col-md-6">
<FormField
label="Заменить на"
name="ptrZoneReplaceTo"
value={editingInterface.ptrZoneReplaceTo || ''}
onChange={(val) => setEditingInterface({ ...editingInterface, ptrZoneReplaceTo: val })}
placeholder="shrt"
/>
<div className="form-text">
На что заменить (например, "shrt"). Результат: DNS "selectel.msk.rt.shx.su" PTR "selectel.msk.shrt.su"
</div>
</div>
{editingInterface.serverId && editingInterface.ptrZoneReplaceFrom && editingInterface.ptrZoneReplaceTo && (() => {
const server = getServerInfo(editingInterface.serverId);
const originalDns = server?.dns || '';
const ptrZone = generatePtrZone(originalDns, editingInterface);
return originalDns ? (
<div className="col-12">
<div className="card card-sm border-info">
<div className="card-body p-2">
<div className="small">
<div className="mb-1">
<strong>DNS:</strong> <code>{originalDns}</code>
</div>
<div>
<strong>PTR зона:</strong> <code className="text-info">{ptrZone}</code>
</div>
</div>
</div>
</div>
</div>
) : null;
})()}
</div>
</div>
</div>
)}
</FormModal>
{/* Interface Template Modal */}
<FormModal
show={interfaceTemplateModalOpen}
onClose={() => {
setInterfaceTemplateModalOpen(false);
setTemplateServer1('');
setTemplateServer2('');
setTemplateType('GRE');
setTemplateNamePrefix('');
setTemplateName1('');
setTemplateName2('');
setTemplateIpsecPasswordId('');
setTemplateIpPoolId('');
setTemplateMtu('');
setTemplateTunnelCount(1);
setTemplateServerPairs([]);
}}
onSubmit={handleCreateInterfaceFromTemplate}
title="Создать интерфейс из шаблона"
submitLabel={templateTunnelCount > 1 ? `Создать ${templateTunnelCount} туннелей` : "Создать интерфейс"}
submitIcon={IconSparkles}
size="lg"
>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label required">Количество туннелей</label>
<input
type="number"
className="form-control"
value={templateTunnelCount}
onChange={(e) => {
const value = parseInt(e.target.value) || 1;
const newCount = Math.max(1, Math.min(100, value));
setTemplateTunnelCount(newCount);
// Синхронизируем количество пар
if (newCount > templateServerPairs.length) {
const newPairs = [...templateServerPairs];
while (newPairs.length < newCount) {
newPairs.push({
server1: '',
server2: '',
name1: '',
name2: '',
ipsecPasswordId: '',
ipPoolId: ''
});
}
setTemplateServerPairs(newPairs);
} else if (newCount < templateServerPairs.length) {
setTemplateServerPairs(templateServerPairs.slice(0, newCount));
}
}}
min="1"
max="100"
/>
<div className="form-text">
{templateTunnelCount > 1
? `Настройте ${templateTunnelCount} пар серверов ниже`
: 'Сколько туннелей создать между выбранными серверами (1-100)'}
</div>
</div>
<div className="col-md-6">
<label className="form-label required">Тип интерфейса</label>
<FormField
name="type"
type="select"
value={templateType}
onChange={(val) => setTemplateType(val)}
options={INTERFACE_TYPES}
/>
</div>
<div className="col-md-6">
<label className="form-label">Префикс имени (опционально)</label>
<input
type="text"
className="form-control"
value={templateNamePrefix}
onChange={(e) => setTemplateNamePrefix(e.target.value)}
placeholder="tunnel"
/>
<div className="form-text">Будет добавлен перед именем интерфейса</div>
</div>
{(templateTunnelCount === 1) && (
<>
<div className="col-md-6">
<label className="form-label required">Сервер 1</label>
<ServerAutocompleteInput
value={templateServer1}
onChange={(val) => setTemplateServer1(val)}
servers={servers.filter(s => s.id !== templateServer2 && s.ip !== templateServer2)}
placeholder="Выберите первый сервер..."
/>
{templateServer1 && (() => {
const server1 = getServerInfo(templateServer1);
return server1 ? (
<div className="form-text">
{server1.dns || server1.ip}
{server1.country && ` • ${countryToFlag(server1.country)} ${server1.country}`}
</div>
) : null;
})()}
</div>
<div className="col-md-6">
<label className="form-label required">Сервер 2</label>
<ServerAutocompleteInput
value={templateServer2}
onChange={(val) => setTemplateServer2(val)}
servers={servers.filter(s => s.id !== templateServer1 && s.ip !== templateServer1)}
placeholder="Выберите второй сервер..."
/>
{templateServer2 && (() => {
const server2 = getServerInfo(templateServer2);
return server2 ? (
<div className="form-text">
{server2.dns || server2.ip}
{server2.country && ` • ${countryToFlag(server2.country)} ${server2.country}`}
</div>
) : null;
})()}
</div>
</>
)}
{templateTunnelCount > 1 && (
<div className="col-12">
<label className="form-label required">Пары серверов</label>
<div className="d-flex flex-column gap-3">
{Array.from({ length: templateTunnelCount }).map((_, index) => {
const pair = templateServerPairs[index] || {
server1: '',
server2: '',
name1: '',
name2: '',
ipsecPasswordId: '',
ipPoolId: ''
};
const updatePair = (updates) => {
const newPairs = [...templateServerPairs];
while (newPairs.length < templateTunnelCount) {
newPairs.push({
server1: '',
server2: '',
name1: '',
name2: '',
ipsecPasswordId: '',
ipPoolId: ''
});
}
newPairs[index] = { ...pair, ...updates };
setTemplateServerPairs(newPairs.slice(0, templateTunnelCount));
};
const server1 = getServerInfo(pair.server1);
const server2 = getServerInfo(pair.server2);
return (
<div key={index} className="card border">
<div className="card-header bg-light d-flex justify-content-between align-items-center">
<span className="fw-semibold">Туннель {index + 1}</span>
</div>
<div className="card-body">
<div className="row g-3">
<div className="col-md-6">
<label className="form-label required">Сервер 1</label>
<ServerAutocompleteInput
value={pair.server1}
onChange={(val) => updatePair({ server1: val })}
servers={servers.filter(s => s.id !== pair.server2 && s.ip !== pair.server2)}
placeholder="Выберите первый сервер..."
/>
{server1 && (
<div className="form-text">
{server1.dns || server1.ip}
{server1.country && ` • ${countryToFlag(server1.country)} ${server1.country}`}
</div>
)}
</div>
<div className="col-md-6">
<label className="form-label required">Сервер 2</label>
<ServerAutocompleteInput
value={pair.server2}
onChange={(val) => updatePair({ server2: val })}
servers={servers.filter(s => s.id !== pair.server1 && s.ip !== pair.server1)}
placeholder="Выберите второй сервер..."
/>
{server2 && (
<div className="form-text">
{server2.dns || server2.ip}
{server2.country && ` • ${countryToFlag(server2.country)} ${server2.country}`}
</div>
)}
</div>
<div className="col-md-6">
<label className="form-label">Имя интерфейса (сервер 1)</label>
<input
type="text"
className="form-control"
value={pair.name1 || ''}
onChange={(e) => updatePair({ name1: e.target.value })}
placeholder={server1 && server2 ? generateInterfaceName(server1, server2, templateType, templateNamePrefix) : ''}
/>
<div className="form-text">Оставьте пустым для автогенерации</div>
</div>
<div className="col-md-6">
<label className="form-label">Имя интерфейса (сервер 2)</label>
<input
type="text"
className="form-control"
value={pair.name2 || ''}
onChange={(e) => updatePair({ name2: e.target.value })}
placeholder={server2 && server1 ? generateInterfaceName(server2, server1, templateType, templateNamePrefix) : ''}
/>
<div className="form-text">Оставьте пустым для автогенерации</div>
</div>
{(templateType === 'IPSec' || templateType === 'GRE') && (
<div className="col-md-6">
<label className="form-label">IPSec пароль (опционально)</label>
<select
className="form-select"
value={pair.ipsecPasswordId || ''}
onChange={(e) => updatePair({ ipsecPasswordId: e.target.value })}
>
<option value="">Использовать глобальный ({templateIpsecPasswordId ? ipsecPasswords.find(p => p.id === templateIpsecPasswordId)?.name || 'выбран' : 'не выбран'})</option>
{ipsecPasswords.map(pwd => (
<option key={pwd.id} value={pwd.id}>
{pwd.name} {pwd.description ? `(${pwd.description})` : ''}
</option>
))}
</select>
</div>
)}
<div className="col-md-6">
<label className="form-label">IP пул (опционально)</label>
<select
className="form-select"
value={pair.ipPoolId || ''}
onChange={(e) => updatePair({ ipPoolId: e.target.value })}
>
<option value="">Использовать глобальный ({templateIpPoolId ? config.ipPools?.find(p => p.id === templateIpPoolId)?.name || 'выбран' : 'автоматический'})</option>
{config.ipPools?.map(pool => (
<option key={pool.id} value={pool.id}>
{pool.name} ({pool.cidr}) {pool.description ? `- ${pool.description}` : ''}
</option>
))}
</select>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{(templateTunnelCount === 1) && (
<>
<div className="col-md-6">
<label className="form-label">Имя интерфейса (сервер 1)</label>
<input
type="text"
className="form-control"
value={templateName1}
onChange={(e) => setTemplateName1(e.target.value)}
placeholder={templateServer1 && templateServer2 ? (() => {
const s1 = getServerInfo(templateServer1);
const s2 = getServerInfo(templateServer2);
return s1 && s2 ? generateInterfaceName(s1, s2, templateType, templateNamePrefix) : '';
})() : ''}
/>
<div className="form-text">Оставьте пустым для автогенерации</div>
</div>
<div className="col-md-6">
<label className="form-label">Имя интерфейса (сервер 2)</label>
<input
type="text"
className="form-control"
value={templateName2}
onChange={(e) => setTemplateName2(e.target.value)}
placeholder={templateServer1 && templateServer2 ? (() => {
const s1 = getServerInfo(templateServer1);
const s2 = getServerInfo(templateServer2);
return s1 && s2 ? generateInterfaceName(s2, s1, templateType, templateNamePrefix) : '';
})() : ''}
/>
<div className="form-text">Оставьте пустым для автогенерации</div>
</div>
</>
)}
{(templateType === 'IPSec' || templateType === 'GRE') && (
<div className="col-12">
<label className="form-label">IPSec пароль (глобальный, используется если не указан для пары)</label>
<div className="input-group">
<select
className="form-select"
value={templateIpsecPasswordId}
onChange={(e) => setTemplateIpsecPasswordId(e.target.value)}
>
<option value="">Не выбран</option>
{ipsecPasswords.map(pwd => (
<option key={pwd.id} value={pwd.id}>
{pwd.name} {pwd.description ? `(${pwd.description})` : ''}
</option>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={() => {
setIpsecPasswordModalMode('add');
setEditingIpsecPassword({ name: '', password: '', description: '' });
setIpsecPasswordModalOpen(true);
}}
title="Создать новый IPSec пароль"
>
<IconPlus size={16} />
</button>
</div>
<div className="form-text">
Выберите сохраненный IPSec пароль или создайте новый (опционально для GRE). Будет использован для всех туннелей, если не указан индивидуально.
</div>
</div>
)}
<div className="col-12">
<label className="form-label">IP пул (глобальный, используется если не указан для пары)</label>
<select
className="form-select"
value={templateIpPoolId}
onChange={(e) => setTemplateIpPoolId(e.target.value)}
>
<option value="">Автоматический подбор (RFC 1918)</option>
{config.ipPools?.map(pool => (
<option key={pool.id} value={pool.id}>
{pool.name} ({pool.cidr}) {pool.description ? `- ${pool.description}` : ''}
</option>
))}
</select>
<div className="form-text">
Выберите IP пул для подбора адресов или оставьте автоматический подбор. Будет использован для всех туннелей, если не указан индивидуально.
</div>
</div>
<div className="col-md-6">
<label className="form-label">MTU (глобальный, используется если не указан для пары)</label>
<input
type="number"
className="form-control"
value={templateMtu}
onChange={(e) => setTemplateMtu(e.target.value)}
placeholder="1500"
/>
<div className="form-text">
Максимальный размер передаваемого пакета (опционально). Будет использован для всех туннелей, если не указан индивидуально.
</div>
</div>
{templateTunnelCount === 1 && templateServer1 && templateServer2 && templateServer1 !== templateServer2 && (() => {
const server1 = getServerInfo(templateServer1);
const server2 = getServerInfo(templateServer2);
if (!server1 || !server2) return null;
const autoName1 = generateInterfaceName(server1, server2, templateType, templateNamePrefix);
const autoName2 = generateInterfaceName(server2, server1, templateType, templateNamePrefix);
const previewName1 = (templateName1.trim() || autoName1).toUpperCase();
const previewName2 = (templateName2.trim() || autoName2).toUpperCase();
const previewLocalIp = generateFreePrivateIp(null, false, null, templateIpPoolId || null);
const previewRemoteIp = previewLocalIp ? generateFreePrivateIp(null, true, previewLocalIp, templateIpPoolId || null) : null;
return (
<>
<div className="col-12">
<div className="card card-sm border">
<div className="card-header bg-light">
<strong>Предпросмотр интерфейса:</strong>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-6">
<div className="mb-2">
<strong>Сервер 1 ({server1.dns || server1.ip}):</strong>
<div className="ms-2">
<div>Имя: {previewName1}</div>
<div>Local IP: {previewLocalIp || '—'}</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="mb-2">
<strong>Сервер 2 ({server2.dns || server2.ip}):</strong>
<div className="ms-2">
<div>Имя: {previewName2}</div>
<div>Local IP: {previewRemoteIp || '—'}</div>
</div>
</div>
</div>
{previewLocalIp && previewRemoteIp && (
<div className="col-12 text-center">
<IconArrowsRightLeft size={20} className="text-muted" />
<span className="ms-2 text-muted">{previewLocalIp} {previewRemoteIp}</span>
</div>
)}
</div>
</div>
</div>
</div>
</>
);
})()}
{templateTunnelCount > 1 && templateServerPairs.length === templateTunnelCount && (
<div className="col-12">
<label className="form-label">Предпросмотр туннелей:</label>
<div className="d-flex flex-column gap-3">
{templateServerPairs.map((pair, index) => {
const server1 = getServerInfo(pair.server1);
const server2 = getServerInfo(pair.server2);
if (!server1 || !server2 || !pair.server1 || !pair.server2 || pair.server1 === pair.server2) {
return null;
}
const baseName1 = generateInterfaceName(server1, server2, templateType, templateNamePrefix);
const baseName2 = generateInterfaceName(server2, server1, templateType, templateNamePrefix);
const previewName1 = (pair.name1?.trim() || baseName1).toUpperCase();
const previewName2 = (pair.name2?.trim() || baseName2).toUpperCase();
// Используем настройки из пары или глобальные для предпросмотра
const pairIpPoolId = pair.ipPoolId || templateIpPoolId;
// Генерируем предпросмотр IP (используем временный Set для предпросмотра)
const previewUsedIps = new Set(getAllUsedIpsSet);
// Добавляем IP из предыдущих туннелей в предпросмотре
for (let i = 0; i < index; i++) {
const prevPair = templateServerPairs[i];
if (prevPair.server1 && prevPair.server2) {
const prevPairIpPoolId = prevPair.ipPoolId || templateIpPoolId;
const prevPreviewLocal = generateFreePrivateIp(null, false, null, prevPairIpPoolId || null, previewUsedIps);
if (prevPreviewLocal) {
previewUsedIps.add(prevPreviewLocal);
const prevPreviewRemote = generateFreePrivateIp(null, true, prevPreviewLocal, prevPairIpPoolId || null, previewUsedIps);
if (prevPreviewRemote && prevPreviewRemote !== prevPreviewLocal) {
previewUsedIps.add(prevPreviewRemote);
}
}
}
}
// Генерируем IP для текущего туннеля
let previewLocalIp = generateFreePrivateIp(null, false, null, pairIpPoolId || null, previewUsedIps);
let previewRemoteIp = null;
if (previewLocalIp) {
// Генерируем Remote IP на основе Local IP
previewRemoteIp = generateFreePrivateIp(null, true, previewLocalIp, pairIpPoolId || null, previewUsedIps);
// Если Remote IP не сгенерировался или совпадает с Local IP, пробуем найти другую пару
if (!previewRemoteIp || previewRemoteIp === previewLocalIp) {
// Добавляем текущий Local IP в использованные и пробуем найти другую пару
previewUsedIps.add(previewLocalIp);
const altPreviewLocalIp = generateFreePrivateIp(null, false, null, pairIpPoolId || null, previewUsedIps);
if (altPreviewLocalIp && altPreviewLocalIp !== previewLocalIp) {
const altPreviewRemoteIp = generateFreePrivateIp(null, true, altPreviewLocalIp, pairIpPoolId || null, previewUsedIps);
if (altPreviewRemoteIp && altPreviewRemoteIp !== altPreviewLocalIp) {
previewLocalIp = altPreviewLocalIp;
previewRemoteIp = altPreviewRemoteIp;
} else {
// Если все равно не получилось, удаляем альтернативный Local IP
previewUsedIps.delete(altPreviewLocalIp);
}
}
}
}
return (
<div key={index} className="card card-sm border">
<div className="card-header bg-light">
<strong>Туннель {index + 1}:</strong>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-6">
<div className="mb-2">
<strong>Сервер 1 ({server1.dns || server1.ip}):</strong>
<div className="ms-2">
<div>Имя: {previewName1}</div>
<div>Local IP: {previewLocalIp || '—'}</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="mb-2">
<strong>Сервер 2 ({server2.dns || server2.ip}):</strong>
<div className="ms-2">
<div>Имя: {previewName2}</div>
<div>Local IP: {previewRemoteIp || '—'}</div>
</div>
</div>
</div>
{previewLocalIp && previewRemoteIp && (
<div className="col-12 text-center">
<IconArrowsRightLeft size={20} className="text-muted" />
<span className="ms-2 text-muted">{previewLocalIp} {previewRemoteIp}</span>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</FormModal>
{/* IPSec Password Modal */}
<FormModal
show={ipsecPasswordModalOpen}
onClose={() => {
setIpsecPasswordModalOpen(false);
setEditingIpsecPassword(null);
}}
onSubmit={handleSaveIpsecPassword}
title={ipsecPasswordModalMode === 'add' ? 'Добавить IPSec пароль' : 'Редактировать IPSec пароль'}
submitLabel={ipsecPasswordModalMode === 'add' ? 'Создать' : 'Сохранить'}
submitIcon={ipsecPasswordModalMode === 'add' ? IconPlus : IconEdit}
>
{editingIpsecPassword && (
<div className="row g-3">
<div className="col-12">
<FormField
label="Название"
name="name"
value={editingIpsecPassword.name}
onChange={(val) => setEditingIpsecPassword({ ...editingIpsecPassword, name: val })}
placeholder="Основной IPSec пароль"
required
/>
</div>
<div className="col-12">
<FormField
label="Пароль"
name="password"
type="password"
value={editingIpsecPassword.password}
onChange={(val) => setEditingIpsecPassword({ ...editingIpsecPassword, password: val })}
placeholder="Введите пароль"
required
/>
<div className="form-text">Пароль будет зашифрован перед сохранением в S3</div>
</div>
<div className="col-12">
<FormField
label="Описание"
name="description"
value={editingIpsecPassword.description || ''}
onChange={(val) => setEditingIpsecPassword({ ...editingIpsecPassword, description: val })}
placeholder="Описание использования пароля"
/>
</div>
</div>
)}
</FormModal>
{/* IPSec Passwords List Modal */}
{ipsecPasswordsListModalOpen && (
<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 d-flex align-items-center">
<IconLock size={20} className="me-2" />
Управление IPSec паролями
</h5>
<button type="button" className="btn-close" onClick={() => setIpsecPasswordsListModalOpen(false)}></button>
</div>
<div className="modal-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 className="mb-0">Сохраненные IPSec пароли</h6>
<div className="text-muted small">Пароли хранятся в зашифрованном виде в S3</div>
</div>
<button
className="btn btn-primary btn-sm"
onClick={() => {
setIpsecPasswordModalMode('add');
setEditingIpsecPassword({ name: '', password: '', description: '' });
setIpsecPasswordModalOpen(true);
}}
>
<IconPlus size={16} className="me-1" />
Добавить пароль
</button>
</div>
{ipsecPasswords.length === 0 ? (
<div className="empty">
<div className="empty-img">
<IconLock size={48} />
</div>
<p className="empty-title">IPSec пароли не найдены</p>
<p className="empty-subtitle text-muted">
Создайте первый IPSec пароль для использования в шаблонах интерфейсов
</p>
<div className="empty-action">
<button
className="btn btn-primary"
onClick={() => {
setIpsecPasswordModalMode('add');
setEditingIpsecPassword({ name: '', password: '', description: '' });
setIpsecPasswordModalOpen(true);
}}
>
<IconPlus size={16} className="me-2" />
Добавить пароль
</button>
</div>
</div>
) : (
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Название</th>
<th>Описание</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{ipsecPasswords.map(pwd => (
<tr key={pwd.id}>
<td><strong>{pwd.name}</strong></td>
<td className="text-muted">{pwd.description || '—'}</td>
<td className="text-end">
<div className="btn-list gap-1">
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => {
handleEditIpsecPassword(pwd.id);
setIpsecPasswordsListModalOpen(false);
}}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-ghost-danger btn-icon btn-sm"
onClick={() => {
if (confirm(`Удалить пароль "${pwd.name}"?`)) {
handleDeleteIpsecPassword(pwd.id);
}
}}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setIpsecPasswordsListModalOpen(false)}>
Закрыть
</button>
</div>
</div>
</div>
</div>
)}
{/* Pool Modal */}
<FormModal
show={poolModalOpen}
onClose={() => { setPoolModalOpen(false); setEditingPool(null); }}
onSubmit={() => editingPool && handleSavePool(editingPool)}
title={poolModalMode === 'add' ? 'Добавить IP пул' : 'Редактировать IP пул'}
submitLabel={poolModalMode === 'add' ? 'Добавить' : 'Сохранить'}
submitIcon={poolModalMode === 'add' ? IconPlus : IconEdit}
>
{editingPool && (
<div className="row g-3">
<div className="col-md-6">
<FormField
label="Название"
name="name"
value={editingPool.name}
onChange={(val) => setEditingPool({ ...editingPool, name: val })}
placeholder="GRE Tunnels"
required
/>
</div>
<div className="col-md-6">
<FormField
label="CIDR"
name="cidr"
value={editingPool.cidr}
onChange={(val) => setEditingPool({ ...editingPool, cidr: val })}
placeholder="10.10.0.0/24"
required
/>
</div>
<div className="col-12">
<FormField
label="Описание"
name="description"
value={editingPool.description}
onChange={(val) => setEditingPool({ ...editingPool, description: val })}
placeholder="Пул для GRE туннелей между серверами"
/>
</div>
</div>
)}
</FormModal>
{/* Delete Confirmation Modal */}
<ConfirmModal
show={deleteModalOpen}
onClose={() => { setDeleteModalOpen(false); setItemToDelete(null); }}
onConfirm={executeDelete}
title="Подтверждение удаления"
message={
itemToDelete ? (
<>
<p>Вы уверены, что хотите удалить <strong>{itemToDelete.ip || itemToDelete.name || itemToDelete.cidr || 'этот элемент'}</strong>?</p>
<p className="text-muted mb-0">Это действие нельзя отменить.</p>
</>
) : null
}
confirmLabel="Удалить"
variant="danger"
icon={IconTrash}
/>
{/* MikroTik Code Modal */}
{mikrotikCodeModalOpen && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-xl">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
Предварительный просмотр конфигурации MikroTik
</h5>
<button type="button" className="btn-close" onClick={() => setMikrotikCodeModalOpen(false)}></button>
</div>
<div className="modal-body">
<div className="alert alert-info mb-3">
Конфигурация MikroTik RouterOS для рекурсивных маршрутов и IP адресов интерфейсов
</div>
{Object.keys(groupedMikrotikCode).length > 0 ? (
<div className="d-flex flex-column gap-4">
{Object.entries(groupedMikrotikCode)
.sort(([a], [b]) => a.localeCompare(b))
.map(([serverName, serverData]) => {
const server = serverData.server;
const isUnassigned = serverName === '__unassigned__';
return (
<div key={serverName} className="border rounded p-3">
{/* Заголовок сервера */}
<div className="d-flex align-items-center mb-3 pb-2 border-bottom">
<div className="d-flex align-items-center flex-fill">
{isUnassigned ? (
<>
<span className="avatar avatar-sm bg-secondary-lt me-2">
<IconServer size={16} className="text-muted" />
</span>
<span className="text-muted">Без привязки к серверу</span>
</>
) : (
<>
<span className="avatar avatar-sm bg-primary-lt me-2">
<IconServer size={16} className="text-primary" />
</span>
<div>
<div className="fw-bold">{serverName}</div>
{server && (
<div className="text-muted small">
{server.provider && <span>{server.provider}</span>}
{server.provider && server.country && <span className="mx-1"></span>}
{server.country && (
<span>{countryToFlag(server.country)} {server.country}</span>
)}
{server.ip && server.ip !== serverName && (
<>
{(server.provider || server.country) && <span className="mx-1"></span>}
<span>{server.ip}</span>
</>
)}
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Блоки кода для этого сервера */}
<div className="d-flex flex-column gap-3">
{serverData.blocks.map((block, blockIndex) => {
const blockTitle = block.type === 'recursive-routes'
? 'Рекурсивные маршруты'
: block.type === 'interface-addresses'
? 'IP адреса интерфейсов'
: 'Конфигурация';
return (
<div key={blockIndex} className="border rounded overflow-hidden">
{/* Заголовок блока */}
<div className="bg-secondary-lt px-3 py-2 d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center">
<IconCode size={18} className="text-secondary me-2" />
<span className="fw-medium">{blockTitle}</span>
</div>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => handleCopyBlock(block.code)}
title="Копировать этот блок"
>
<IconCopy size={16} className="me-1" />
Копировать
</button>
</div>
{/* Код блока */}
<div className="bg-dark text-white p-3">
<pre className="mb-0" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '0.875rem' }}>
<code className="text-white">{block.code}</code>
</pre>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="alert alert-warning mb-0">
Нет данных для генерации конфигурации
</div>
)}
<div className="mt-3 text-muted small">
<p className="mb-1"><strong>Инструкция по применению:</strong></p>
<ol className="mb-0">
<li>Скопируйте конфигурацию в буфер обмена</li>
<li>Подключитесь к MikroTik через SSH или Winbox</li>
<li>Вставьте конфигурацию в терминал</li>
<li>Проверьте применение командой: <code>/ip route print</code> или <code>/ip address print</code></li>
</ol>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setMikrotikCodeModalOpen(false)}>
Закрыть
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleCopyAllMikrotikCode}
disabled={!Array.isArray(generatedMikrotikCode) || generatedMikrotikCode.length === 0}
>
<IconCopy className="me-2" />
Копировать всё
</button>
</div>
</div>
</div>
</div>
)}
{/* Gateway Template Modal */}
<FormModal
show={gatewayTemplateModalOpen}
onClose={() => {
setGatewayTemplateModalOpen(false);
setSelectedTemplate(null);
setTemplateServerId('');
setTemplateBaseIp('');
setTemplateGateways([]);
}}
onSubmit={handleCreateGatewaysFromTemplate}
title="Создать gateways из шаблона"
submitLabel={selectedTemplate ? `Создать ${templateGateways.length} gateways` : "Выберите шаблон"}
submitIcon={IconSparkles}
size="lg"
submitDisabled={!selectedTemplate || !templateServerId || !templateBaseIp}
>
<div className="row g-3">
{/* Выбор шаблона */}
<div className="col-12">
<label className="form-label required">Шаблон набора gateways</label>
<div className="row g-2">
{GATEWAY_TEMPLATES.map(template => {
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер
const previewIps = template.gateways.map((gw, idx) => {
// Для прямых gateways показываем IP сервера, если выбран
if (gw.type === 'direct' && templateServerId) {
const server = getServerInfo(templateServerId);
if (server && server.ip) {
return server.ip;
}
}
// Для рекурсивных gateways генерируем из базового IP
if (gw.type === 'recursive' && templateBaseIp && gw.ipTemplate) {
const normalizedBaseIp = normalizeBaseIp(templateBaseIp);
if (normalizedBaseIp && normalizedBaseIp.split('.').length >= 3) {
return gw.ipTemplate.replace('{baseIp}', normalizedBaseIp);
}
}
return '—';
});
return (
<div key={template.id} className="col-12 col-md-6">
<div
className={`card cursor-pointer ${selectedTemplate?.id === template.id ? 'border-primary' : ''}`}
style={{
transition: 'all 0.2s',
borderWidth: selectedTemplate?.id === template.id ? '2px' : '1px',
backgroundColor: selectedTemplate?.id === template.id ? 'rgba(32, 107, 196, 0.05)' : 'transparent'
}}
onClick={() => handleSelectTemplate(template)}
>
<div className="card-body p-3">
<div className="d-flex align-items-start">
<div className="flex-grow-1">
<h5 className="mb-1 fw-bold">{template.name}</h5>
<p className="text-muted small mb-2">{template.description}</p>
<div className="d-flex align-items-center gap-2 mb-2">
<span className="badge bg-primary-lt text-primary">
{template.gateways.length} {template.gateways.length === 1 ? 'gateway' : 'gateways'}
</span>
<span className="text-muted small">
{template.gateways.filter(g => g.type === 'direct').length} прямых, {template.gateways.filter(g => g.type === 'recursive').length} рекурсивных
</span>
</div>
{templateBaseIp && templateBaseIp.trim().split('.').length >= 3 && templateServerId && (
<div className="mt-2">
<div className="text-muted small mb-1">Предпросмотр IP:</div>
<div className="d-flex flex-wrap gap-1">
{previewIps.map((ip, idx) => (
<code key={idx} className="small" style={{ fontSize: '0.75rem', backgroundColor: 'rgba(0, 0, 0, 0.05)', padding: '2px 6px', borderRadius: '3px' }}>
{ip}
</code>
))}
</div>
</div>
)}
</div>
{selectedTemplate?.id === template.id && (
<IconCircleFilled size={20} className="text-primary flex-shrink-0" />
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Общие параметры */}
{selectedTemplate && (
<>
<div className="col-12">
<hr />
<h5 className="mb-3">Общие параметры</h5>
</div>
<div className="col-12">
<label className="form-label required">Сервер</label>
<ServerAutocompleteInput
value={templateServerId}
onChange={(val) => setTemplateServerId(val)}
servers={servers}
placeholder="Выберите сервер для всех gateways..."
/>
</div>
<div className="col-12">
<label className="form-label required">Базовый IP адрес</label>
<input
type="text"
className="form-control"
value={templateBaseIp}
onChange={(e) => setTemplateBaseIp(e.target.value)}
placeholder="10.9.9 или 10.9.9.1 (минимум 3 октета, полный IP будет нормализован)"
/>
<div className="form-text">
Базовый IP для генерации адресов рекурсивных gateways. Можно ввести 3 октета (например, "10.9.9") или полный IP (например, "10.9.9.1") - система автоматически возьмет первые 3 октета. Для "10.9.9" будут созданы IP: 10.9.9.1, 10.9.9.2, 10.9.9.3 и т.д. Прямые gateways будут использовать IP адрес выбранного сервера.
</div>
</div>
{/* Редактирование отдельных gateways */}
<div className="col-12">
<hr />
<h5 className="mb-3">Настройка gateways</h5>
<div className="d-flex flex-column gap-3">
{templateGateways.map((gw, index) => {
const gatewayType = GATEWAY_TYPES.find(t => t.value === gw.type);
return (
<div key={gw.id || index} className="card border">
<div className="card-header py-2" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)' }}>
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center gap-2">
<span className={`badge bg-${gatewayType?.color || 'secondary'}-lt text-${gatewayType?.color || 'secondary'}`}>
{gatewayType?.label || gw.type}
</span>
<strong>Gateway {index + 1}</strong>
</div>
</div>
</div>
<div className="card-body p-3">
<div className="row g-2">
<div className="col-md-6">
<label className="form-label small">Название</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.description || ''}
onChange={(e) => {
const newGateways = [...templateGateways];
newGateways[index].description = e.target.value;
setTemplateGateways(newGateways);
}}
placeholder="Название gateway"
/>
</div>
<div className="col-md-6">
<label className="form-label small">IP адрес</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.ip || ''}
onChange={(e) => {
const newGateways = [...templateGateways];
newGateways[index].ip = e.target.value;
setTemplateGateways(newGateways);
}}
placeholder={
gw.type === 'direct' && templateServerId
? (getServerInfo(templateServerId)?.ip || 'IP сервера')
: (gw.ipTemplate ? gw.ipTemplate.replace('{baseIp}', normalizeBaseIp(templateBaseIp) || 'X.X.X') : 'IP адрес')
}
/>
<div className="form-text small">
{gw.type === 'direct'
? (templateServerId ? `IP сервера: ${getServerInfo(templateServerId)?.ip || '—'}` : 'Выберите сервер для автоматической подстановки IP')
: `Шаблон: ${gw.ipTemplate || '—'}`
}
</div>
</div>
<div className="col-md-6">
<label className="form-label small">Страна</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.country || ''}
onChange={(e) => {
const newGateways = [...templateGateways];
newGateways[index].country = e.target.value.toUpperCase().slice(0, 2);
setTemplateGateways(newGateways);
}}
placeholder="SE, FI, RU..."
maxLength={2}
/>
</div>
{gw.type === 'recursive' && (
<div className="col-12">
<label className="form-label small">Родительские gateway/интерфейсы</label>
<div className="d-flex flex-column gap-2">
{(gw.parentGateways || []).map((parent, parentIndex) => {
const parentInfo = getParentGateway(parent.id, templateGateways);
return (
<div key={parentIndex} className="d-flex align-items-center gap-2">
<div className="flex-grow-1">
<GatewayAutocompleteInput
value={parent.id}
onChange={(val) => {
const newGateways = [...templateGateways];
if (!newGateways[index].parentGateways) {
newGateways[index].parentGateways = [];
}
newGateways[index].parentGateways[parentIndex] = { ...newGateways[index].parentGateways[parentIndex], id: val };
setTemplateGateways(newGateways);
}}
gateways={config.gateways}
interfaces={config.tunnelInterfaces}
templateGateways={templateGateways}
serverId={templateServerId}
excludeGatewayId={gw.id}
placeholder="Выберите родительский gateway или интерфейс..."
/>
</div>
<div style={{ width: '100px' }}>
<input
type="number"
className="form-control form-control-sm"
value={parent.distance || ''}
onChange={(e) => {
const newGateways = [...templateGateways];
if (!newGateways[index].parentGateways) {
newGateways[index].parentGateways = [];
}
newGateways[index].parentGateways[parentIndex] = {
...newGateways[index].parentGateways[parentIndex],
distance: e.target.value ? parseInt(e.target.value) : undefined
};
setTemplateGateways(newGateways);
}}
placeholder="Distance"
/>
</div>
<button
type="button"
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => {
const newGateways = [...templateGateways];
newGateways[index].parentGateways = (newGateways[index].parentGateways || []).filter((_, i) => i !== parentIndex);
setTemplateGateways(newGateways);
}}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
);
})}
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => {
const newGateways = [...templateGateways];
if (!newGateways[index].parentGateways) {
newGateways[index].parentGateways = [];
}
newGateways[index].parentGateways.push({ id: '', distance: undefined });
setTemplateGateways(newGateways);
}}
>
<IconPlus size={16} className="me-1" />
Добавить родительский gateway
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</>
)}
</div>
</FormModal>
</div>
);
}
export default NetworkConfigManager;