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

3505 lines
152 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 { 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,
} 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' },
];
// Пустые объекты
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)
});
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
// === 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('');
// === 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 пароль
// === 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 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 resetFilters = () => {
setSearchTerm('');
setProviderFilter('');
setServerFilter('');
setTypeFilter('');
setGatewayTypeFilter('');
};
const hasActiveFilters = searchTerm || providerFilter || serverFilter || typeFilter || gatewayTypeFilter;
// === 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}`;
};
// === Создание интерфейса из шаблона ===
const handleCreateInterfaceFromTemplate = () => {
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);
const name2 = templateName2.trim() || generateInterfaceName(server2, server1, templateType, templateNamePrefix);
// Генерируем IP адреса
const localIp = generateFreePrivateIp(null, false, null);
if (!localIp) {
notify.error('Не удалось найти свободный Local IP адрес');
return;
}
const remoteIp = generateFreePrivateIp(null, true, localIp);
if (!remoteIp) {
notify.error('Не удалось найти свободный Remote IP адрес');
return;
}
// Проверяем конфликты
const newInterface = {
...getEmptyInterface(),
name: name1,
name2: name2,
type: templateType,
localIp: localIp,
remoteIp: remoteIp,
serverId: templateServer1,
serverId2: templateServer2,
ipsecPasswordId: templateType === 'IPSec' && templateIpsecPasswordId ? templateIpsecPasswordId : ''
};
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('');
};
// === 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 (четные базовые адреса)
const generateFreePrivateIp = (excludeIp = null, forRemote = false, pairedLocalIp = null) => {
const usedIps = new Set(getAllUsedIpsSet);
if (excludeIp) {
usedIps.delete(excludeIp.trim());
}
// Если подбираем 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
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 handleSuggestLocalIp = () => {
const freeIp = generateFreePrivateIp(editingInterface?.localIp, false, null);
if (freeIp) {
setEditingInterface({ ...editingInterface, localIp: freeIp });
notify.success(`Подобран Local IP: ${freeIp}`);
} else {
notify.error('Не удалось найти свободный IP адрес');
}
};
const handleSuggestRemoteIp = () => {
// Если есть Local IP, генерируем парный Remote IP в той же сети /30
const freeIp = generateFreePrivateIp(
editingInterface?.remoteIp,
true,
editingInterface?.localIp || null
);
if (freeIp) {
setEditingInterface({ ...editingInterface, remoteIp: freeIp });
notify.success(`Подобран Remote IP: ${freeIp}`);
} else {
notify.error('Не удалось найти свободный IP адрес');
}
};
// === Получение реестра всех используемых 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: server?.dns || '—' // 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: server2?.dns || '—' // 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) => {
// Валидация: проверка пересечений IP адресов
const conflicts = checkInterfaceIpConflict(
ifaceData,
interfaceModalMode === 'edit' ? ifaceData.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, ifaceData],
}));
notify.success('Интерфейс добавлен');
} else {
setConfig(prev => ({
...prev,
tunnelInterfaces: prev.tunnelInterfaces.map(i => i.id === ifaceData.id ? ifaceData : 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 {
if (ipsecPasswordModalMode === 'add') {
await api.post('/ipsec-passwords', {
name: editingIpsecPassword.name,
password: editingIpsecPassword.password,
description: editingIpsecPassword.description || ''
});
notify.success('IPSec пароль создан');
} else {
await api.put(`/ipsec-passwords/${editingIpsecPassword.id}`, {
name: editingIpsecPassword.name,
password: editingIpsecPassword.password,
description: editingIpsecPassword.description || ''
});
notify.success('IPSec пароль обновлён');
}
await fetchIpsecPasswords();
setIpsecPasswordModalOpen(false);
setEditingIpsecPassword(null);
} 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 для настройки IP адресов интерфейсов ===
const generateMikrotikInterfaceAddresses = () => {
const interfacesWithBothServers = (config.tunnelInterfaces || []).filter(i =>
i.serverId && i.serverId2 && i.localIp && i.remoteIp
);
if (interfacesWithBothServers.length === 0) {
return [];
}
// Группируем по серверам
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
});
// Для второго сервера - МЕНЯЕМ МЕСТАМИ: 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
});
});
// Сортируем серверы по имени для консистентности
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';
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`;
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';
blocks.push({
type: 'interface-addresses',
serverName,
server,
code
});
});
return blocks;
};
// === Генерация кода MikroTik для рекурсивных маршрутов ===
const generateMikrotikRecursiveRoutes = () => {
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 = generateMikrotikInterfaceAddresses();
if (interfaceBlocks && interfaceBlocks.length > 0) {
blocks.push(...interfaceBlocks);
}
return blocks;
};
// === Обработчик кнопки "Код для MikroTik" ===
const handleGenerateMikrotikCode = () => {
const code = generateMikrotikRecursiveRoutes();
setGeneratedMikrotikCode(code);
setMikrotikCodeModalOpen(true);
};
// === Копирование кода 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 renderServerGroup = (serverId, items, type) => {
const server = getServerInfo(serverId);
const isUnassigned = serverId === '__unassigned__';
return (
<div key={serverId} className="mb-4">
{/* Server header */}
<div className="d-flex align-items-center mb-2 px-1">
<div className="d-flex align-items-center">
{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>
<span className="fw-medium">
{server?.dns ? server.dns.split('.')[0] : (server?.name || serverId)}
</span>
{server?.dns && (
<span className="text-muted ms-2 small">
{server.dns}
</span>
)}
{server && (
<span className="text-muted ms-2 small">
{server.ip}
{server.country && (
<span className="ms-1">{countryToFlag(server.country)} {server.country}</span>
)}
</span>
)}
</div>
</>
)}
</div>
<span className="badge bg-secondary-lt ms-auto">{items.length}</span>
</div>
{/* Items */}
<div className="row row-cards">
{items.map(item => (
<div key={item.id} className="col-12 col-md-6 col-lg-6">
{type === 'gateway' ? renderGatewayCard(item) :
type === 'interface' ? renderInterfaceCard(item, serverId) :
type === 'pool' ? renderPoolCard(item) : null}
</div>
))}
</div>
</div>
);
};
// === Получение родительского gateway или интерфейса ===
const getParentGateway = (parentId) => {
if (!parentId) return null;
// Сначала ищем в 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)
});
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 mb-2" 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-2 px-3 border-start" style={{ flexShrink: 0 }}>
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditPool(pool)}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeletePool(pool)}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</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 mb-2" 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" style={{ flex: '0 0 auto', minWidth: 0 }}>
{displayCountry && (
<span className="me-2" style={{ fontSize: '1.25rem', flexShrink: 0 }}>{countryToFlag(displayCountry)}</span>
)}
<div>
<span className="fw-semibold">{gateway.description || gateway.ip || '—'}</span>
</div>
</div>
</div>
{/* Маршрут ip->ip сразу под названием */}
{displayText && (
<div className="mb-1">
<span className="badge bg-success-lt text-success" style={{ whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{displayText}
</span>
</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 || gateway.serverId}
</code>
)}
</div>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2 px-3 border-start" style={{ flexShrink: 0 }}>
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditGateway(gateway)}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteGateway(gateway)}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</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 mb-2" 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" style={{ flex: '0 0 auto', minWidth: 0 }}>
{displayCountry && (
<span className="me-2" style={{ fontSize: '1.25rem', flexShrink: 0 }}>{countryToFlag(displayCountry)}</span>
)}
<div>
<span className="fw-semibold">{interfaceName}</span>
</div>
</div>
</div>
{/* Маршрут между Local IP и Remote IP сразу под названием */}
{displayLocalIp !== '—' && displayRemoteIp !== '—' && (
<div className="mb-1">
<span className="badge bg-success-lt text-success" style={{ whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{displayLocalIp} {displayRemoteIp}
</span>
</div>
)}
{/* Нижняя строка: DNS */}
<div className="d-flex align-items-center gap-2 flex-wrap">
{displayServerName && (
<code className="text-muted" style={{ fontSize: '0.8rem', wordBreak: 'break-all', overflowWrap: 'break-word' }}>
{displayServerName}
</code>
)}
</div>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2 px-3 border-start" style={{ flexShrink: 0 }}>
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditInterface(iface)}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteInterface(iface)}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</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>
)}
{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
<div>
{Object.entries(gatewaysByServer).map(([serverId, gateways]) =>
renderServerGroup(serverId, gateways, '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={() => 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
<div>
{Object.entries(interfacesByServer).map(([serverId, interfaces]) =>
renderServerGroup(serverId, interfaces, '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={() => 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}
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' ? '' : editingInterface.ipsecPasswordId })}
options={INTERFACE_TYPES}
/>
</div>
{editingInterface.type === 'IPSec' && (
<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 пароль или создайте новый
</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;
})()}
</div>
)}
</FormModal>
{/* Interface Template Modal */}
<FormModal
show={interfaceTemplateModalOpen}
onClose={() => {
setInterfaceTemplateModalOpen(false);
setTemplateServer1('');
setTemplateServer2('');
setTemplateType('GRE');
setTemplateNamePrefix('');
setTemplateName1('');
setTemplateName2('');
setTemplateIpsecPasswordId('');
}}
onSubmit={handleCreateInterfaceFromTemplate}
title="Создать интерфейс из шаблона"
submitLabel="Создать интерфейс"
submitIcon={IconSparkles}
size="lg"
>
<div className="row g-3">
<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>
<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>
{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;
const previewName2 = templateName2.trim() || autoName2;
const previewLocalIp = generateFreePrivateIp(null, false, null);
const previewRemoteIp = previewLocalIp ? generateFreePrivateIp(null, true, previewLocalIp) : null;
return (
<>
<div className="col-md-6">
<label className="form-label">
Имя интерфейса (сервер 1)
{!templateName1 && <span className="text-muted small ms-1">(авто: {autoName1})</span>}
</label>
<input
type="text"
className="form-control"
value={templateName1}
onChange={(e) => setTemplateName1(e.target.value)}
placeholder={autoName1}
/>
<div className="form-text">Оставьте пустым для автогенерации</div>
</div>
<div className="col-md-6">
<label className="form-label">
Имя интерфейса (сервер 2)
{!templateName2 && <span className="text-muted small ms-1">(авто: {autoName2})</span>}
</label>
<input
type="text"
className="form-control"
value={templateName2}
onChange={(e) => setTemplateName2(e.target.value)}
placeholder={autoName2}
/>
<div className="form-text">Оставьте пустым для автогенерации </div>
</div>
{templateType === 'IPSec' && (
<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 пароль или создайте новый
</div>
</div>
)}
<div className="col-12">
<div className="card card-sm border">
<div className="card-body">
<h6 className="card-title mb-3">Предпросмотр интерфейса:</h6>
<div className="row g-2">
<div className="col-md-6">
<div className="small text-muted mb-1">Сервер 1 ({server1.dns || server1.ip}):</div>
<div><strong>Имя:</strong> <code>{previewName1}</code></div>
<div><strong>Local IP:</strong> <code>{previewLocalIp || '—'}</code></div>
</div>
<div className="col-md-6">
<div className="small text-muted mb-1">Сервер 2 ({server2.dns || server2.ip}):</div>
<div><strong>Имя:</strong> <code>{previewName2}</code></div>
<div><strong>Local IP:</strong> <code>{previewRemoteIp || '—'}</code></div>
</div>
{previewLocalIp && previewRemoteIp && (
<div className="col-12 mt-2">
<div className="badge bg-success-lt text-success">
{previewLocalIp} {previewRemoteIp}
</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 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>
)}
</div>
);
}
export default NetworkConfigManager;