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