Files
router-lists-ui/frontend/src/NetworkConfigManager.jsx
T
2026-01-22 14:50:23 +07:00

2234 lines
95 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from 'react';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import FormModal from './components/FormModal.jsx';
import FormField from './components/FormField.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconNetwork,
IconPlus,
IconEdit,
IconTrash,
IconDatabase,
IconServer,
IconWorld,
IconRouter,
IconCopy,
IconSearch,
IconRefresh,
IconFilter,
IconX,
IconCircleFilled,
IconLayoutGrid,
IconList,
IconCode,
IconArrowsRightLeft
} from '@tabler/icons-react';
/**
* Менеджер сетевых настроек
* Справочник IP-адресов, интерфейсов и gateway для серверов
*/
// Типы провайдеров gateway
const GATEWAY_PROVIDERS = [
{ value: 'cloudflare', label: 'Cloudflare', color: 'orange' },
{ value: 'bunny', label: 'Bunny CDN', color: 'yellow' },
{ value: 'fastly', label: 'Fastly', color: 'red' },
{ value: 'telegram', label: 'Telegram', color: 'blue' },
{ value: 'hetzner', label: 'Hetzner', color: 'green' },
{ value: 'yandex', label: 'Yandex Cloud', color: 'cyan' },
{ value: 'custom', label: 'Другой', color: 'secondary' },
];
// Типы туннельных интерфейсов
const INTERFACE_TYPES = [
{ value: 'GRE', label: 'GRE', color: 'blue' },
{ value: 'WireGuard', label: 'WireGuard', color: 'green' },
{ value: 'IPSec', label: 'IPSec', color: 'yellow' },
{ value: 'VXLAN', label: 'VXLAN', color: 'purple' },
{ value: 'OpenVPN', label: 'OpenVPN', color: 'orange' },
];
// Типы gateway
const GATEWAY_TYPES = [
{ value: 'direct', label: 'Прямой', description: 'Прямой доступ в интернет', color: 'green' },
{ value: 'recursive', label: 'Рекурсивный', description: 'Ссылается на другой gateway', color: 'blue' },
];
// Пустые объекты
const getEmptyGateway = () => ({
id: `gw-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
ip: '',
description: '',
serverId: '',
country: '',
type: 'direct',
parentGatewayId: '', // Для рекурсивных - ID родительского gateway
});
const getEmptyInterface = () => ({
id: `if-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
name: '',
name2: '', // Имя интерфейса на втором сервере (опционально)
type: 'GRE',
localIp: '',
remoteIp: '',
serverId: '',
serverId2: '', // Второй сервер (опциональный)
});
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'
// === 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('');
// === MikroTik Code Generation ===
const [mikrotikCodeModalOpen, setMikrotikCodeModalOpen] = useState(false);
const [generatedMikrotikCode, setGeneratedMikrotikCode] = useState([]);
// === Загрузка данных ===
useEffect(() => {
fetchConfig();
fetchServers();
}, []);
const fetchConfig = async () => {
setLoading(true);
try {
const response = await api.get('/network-config');
const data = response.data || getDefaultConfig();
setConfig({
gateways: Array.isArray(data.gateways) ? data.gateways : [],
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 handleSave = async () => {
setSaving(true);
try {
await api.post('/network-config', { domains: config });
notify.success('Сетевые настройки сохранены');
} catch (error) {
console.error('Error saving network config:', error);
notify.error('Не удалось сохранить настройки');
} finally {
setSaving(false);
}
};
// === Получение информации о сервере ===
const getServerInfo = (serverId) => {
if (!serverId) return null;
return servers.find(s => s.id === serverId || s.ip === serverId || s.dns === serverId);
};
const getServerLabel = (serverId) => {
const server = getServerInfo(serverId);
if (!server) return serverId || '—';
return server.dns || server.ip;
};
// === Уникальные серверы для фильтра ===
const uniqueServersInConfig = useMemo(() => {
const allServerIds = new Set([
...(config.gateways || []).map(g => g.serverId).filter(Boolean),
...(config.tunnelInterfaces || []).map(i => i.serverId).filter(Boolean),
]);
return Array.from(allServerIds).map(id => ({
id,
label: getServerLabel(id),
server: getServerInfo(id),
}));
}, [config.gateways, config.tunnelInterfaces, 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 (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter(p =>
p.name?.toLowerCase().includes(term) ||
p.cidr?.toLowerCase().includes(term) ||
p.description?.toLowerCase().includes(term)
);
}
return result;
}, [config.ipPools, searchTerm]);
// === Группировка по серверам ===
const gatewaysByServer = useMemo(() => {
const grouped = {};
filteredGateways.forEach(gw => {
const key = gw.serverId || '__unassigned__';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(gw);
});
return grouped;
}, [filteredGateways]);
const interfacesByServer = useMemo(() => {
const grouped = {};
filteredInterfaces.forEach(iface => {
// Группируем по первому серверу
const key1 = iface.serverId || '__unassigned__';
if (!grouped[key1]) grouped[key1] = [];
grouped[key1].push(iface);
// Если есть второй сервер, также добавляем в его группу
if (iface.serverId2) {
const key2 = iface.serverId2;
if (!grouped[key2]) grouped[key2] = [];
grouped[key2].push(iface);
}
});
return grouped;
}, [filteredInterfaces]);
// === Сброс фильтров ===
const resetFilters = () => {
setSearchTerm('');
setProviderFilter('');
setServerFilter('');
setTypeFilter('');
setGatewayTypeFilter('');
};
const hasActiveFilters = searchTerm || providerFilter || serverFilter || typeFilter || gatewayTypeFilter;
// === CRUD для Gateways ===
const handleAddGateway = () => {
setEditingGateway(getEmptyGateway());
setGatewayModalMode('add');
setGatewayModalOpen(true);
};
const handleEditGateway = (gateway) => {
setEditingGateway({ ...gateway });
setGatewayModalMode('edit');
setGatewayModalOpen(true);
};
const handleSaveGateway = (gatewayData) => {
// Валидация: IP адрес обязателен
if (!gatewayData.ip || !gatewayData.ip.trim()) {
notify.error('IP адрес обязателен');
return;
}
if (gatewayModalMode === 'add') {
// Проверка на дубликат IP
const existingGateway = config.gateways.find(g => g.ip === gatewayData.ip.trim());
if (existingGateway) {
notify.error(`Gateway с IP ${gatewayData.ip} уже существует`);
return;
}
setConfig(prev => ({
...prev,
gateways: [...prev.gateways, gatewayData],
}));
notify.success('Gateway добавлен');
} else {
// При редактировании ищем по ID, но проверяем, не занят ли новый IP другим gateway
const currentGateway = config.gateways.find(g => g.id === gatewayData.id);
if (!currentGateway) {
notify.error('Gateway не найден');
return;
}
// Если IP изменился, проверяем, не занят ли он другим gateway
if (gatewayData.ip.trim() !== currentGateway.ip &&
config.gateways.some(g => g.id !== gatewayData.id && g.ip === gatewayData.ip.trim())) {
notify.error(`Gateway с IP ${gatewayData.ip} уже существует`);
return;
}
setConfig(prev => ({
...prev,
gateways: prev.gateways.map(g => g.id === gatewayData.id ? gatewayData : g),
}));
notify.success('Gateway обновлён');
}
setGatewayModalOpen(false);
setEditingGateway(null);
};
const handleDeleteGateway = (gateway) => {
setItemToDelete(gateway);
setDeleteType('gateway');
setDeleteModalOpen(true);
};
// === CRUD для Interfaces ===
const handleAddInterface = () => {
setEditingInterface(getEmptyInterface());
setInterfaceModalMode('add');
setInterfaceModalOpen(true);
};
const handleEditInterface = (iface) => {
setEditingInterface({ ...iface });
setInterfaceModalMode('edit');
setInterfaceModalOpen(true);
};
const handleSaveInterface = (ifaceData) => {
if (interfaceModalMode === 'add') {
setConfig(prev => ({
...prev,
tunnelInterfaces: [...prev.tunnelInterfaces, ifaceData],
}));
notify.success('Интерфейс добавлен');
} else {
setConfig(prev => ({
...prev,
tunnelInterfaces: prev.tunnelInterfaces.map(i => i.id === ifaceData.id ? ifaceData : i),
}));
notify.success('Интерфейс обновлён');
}
setInterfaceModalOpen(false);
setEditingInterface(null);
};
const handleDeleteInterface = (iface) => {
setItemToDelete(iface);
setDeleteType('interface');
setDeleteModalOpen(true);
};
// === CRUD для IP Pools ===
const handleAddPool = () => {
setEditingPool(getEmptyIpPool());
setPoolModalMode('add');
setPoolModalOpen(true);
};
const handleEditPool = (pool) => {
setEditingPool({ ...pool });
setPoolModalMode('edit');
setPoolModalOpen(true);
};
const handleSavePool = (poolData) => {
if (poolModalMode === 'add') {
setConfig(prev => ({
...prev,
ipPools: [...prev.ipPools, poolData],
}));
notify.success('IP пул добавлен');
} else {
setConfig(prev => ({
...prev,
ipPools: prev.ipPools.map(p => p.id === poolData.id ? poolData : p),
}));
notify.success('IP пул обновлён');
}
setPoolModalOpen(false);
setEditingPool(null);
};
const handleDeletePool = (pool) => {
setItemToDelete(pool);
setDeleteType('pool');
setDeleteModalOpen(true);
};
// === Подтверждение удаления ===
const executeDelete = () => {
if (!itemToDelete) return;
if (deleteType === 'gateway') {
setConfig(prev => ({
...prev,
gateways: prev.gateways.filter(g => g.id !== itemToDelete.id),
}));
notify.success('Gateway удалён');
} else if (deleteType === 'interface') {
setConfig(prev => ({
...prev,
tunnelInterfaces: prev.tunnelInterfaces.filter(i => i.id !== itemToDelete.id),
}));
notify.success('Интерфейс удалён');
} else if (deleteType === 'pool') {
setConfig(prev => ({
...prev,
ipPools: prev.ipPools.filter(p => p.id !== itemToDelete.id),
}));
notify.success('IP пул удалён');
}
setDeleteModalOpen(false);
setItemToDelete(null);
setDeleteType('');
};
// === Copy to clipboard ===
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
};
// === Генерация кода MikroTik для настройки IP адресов интерфейсов ===
const generateMikrotikInterfaceAddresses = () => {
const interfacesWithBothServers = (config.tunnelInterfaces || []).filter(i =>
i.serverId && i.serverId2 && i.localIp && i.remoteIp
);
if (interfacesWithBothServers.length === 0) {
return [];
}
// Группируем по серверам
const interfacesByServer = {};
interfacesWithBothServers.forEach(iface => {
const server1 = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
if (!server1 || !server2) return;
const server1Name = server1.dns || server1.ip || iface.serverId;
const server2Name = server2.dns || server2.ip || iface.serverId2;
// Имя интерфейса для первого сервера
const interfaceName1 = iface.name || `${iface.type}-tunnel`;
// Имя интерфейса для второго сервера - ВСЕГДА используем name2 если указано, иначе name
const interfaceName2 = iface.name2 || iface.name || `${iface.type}-tunnel`;
// Для первого сервера - используем localIp как local, remoteIp как remote
if (!interfacesByServer[server1Name]) {
interfacesByServer[server1Name] = {
serverInfo: server1,
interfaces: []
};
}
interfacesByServer[server1Name].interfaces.push({
interfaceName: interfaceName1,
localIp: iface.localIp,
remoteIp: iface.remoteIp,
type: iface.type,
server2Name,
server2Info: server2
});
// Для второго сервера - МЕНЯЕМ МЕСТАМИ: remoteIp становится localIp, localIp становится remoteIp
// И ВСЕГДА используем name2 (или name если name2 не указано)
if (!interfacesByServer[server2Name]) {
interfacesByServer[server2Name] = {
serverInfo: server2,
interfaces: []
};
}
interfacesByServer[server2Name].interfaces.push({
interfaceName: interfaceName2, // Используем name2 для второго сервера
localIp: iface.remoteIp, // На втором сервере remoteIp первого становится localIp
remoteIp: iface.localIp, // На втором сервере localIp первого становится remoteIp
type: iface.type,
server2Name: server1Name,
server2Info: server1
});
});
// Сортируем серверы по имени для консистентности
const sortedServers = Object.entries(interfacesByServer).sort(([a], [b]) => a.localeCompare(b));
const blocks = [];
sortedServers.forEach(([serverName, serverData]) => {
const server = serverData.serverInfo;
let code = `# Настройка IP адресов интерфейсов для сервера: ${serverName}\n`;
if (server.provider) {
code += `# Провайдер: ${server.provider}\n`;
}
if (server.country) {
code += `# Страна: ${server.country}\n`;
}
if (server.ip && server.ip !== serverName) {
code += `# IP: ${server.ip}\n`;
}
code += '\n';
serverData.interfaces.forEach((iface, ifaceIndex) => {
code += `# Интерфейс ${ifaceIndex + 1}: ${iface.interfaceName} (${iface.type})\n`;
code += `# Связь с сервером: ${iface.server2Name}\n`;
code += `# Local IP: ${iface.localIp}\n`;
code += `# Remote IP: ${iface.remoteIp}\n`;
code += `/ip address add address=${iface.localIp}/32 interface="${iface.interfaceName}" comment="Interface: ${iface.interfaceName} (${iface.type}) to ${iface.server2Name}"\n`;
code += '\n';
});
code += '# Проверка настроенных адресов на этом сервере:\n';
code += '# /ip address print\n';
blocks.push({
type: 'interface-addresses',
serverName,
server,
code
});
});
return blocks;
};
// === Генерация кода MikroTik для рекурсивных маршрутов ===
const generateMikrotikRecursiveRoutes = () => {
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
const blocks = [];
// Если есть рекурсивные gateway, генерируем блоки для них
if (recursiveGateways.length > 0) {
// Группируем по серверам для удобства
const gatewaysByServer = {};
recursiveGateways.forEach(gw => {
const serverId = gw.serverId || '__unassigned__';
if (!gatewaysByServer[serverId]) {
gatewaysByServer[serverId] = [];
}
gatewaysByServer[serverId].push(gw);
});
// Генерируем блоки для рекурсивных маршрутов по серверам
Object.entries(gatewaysByServer).forEach(([serverId, gateways]) => {
const server = getServerInfo(serverId);
const serverName = server?.dns || server?.ip || serverId;
let code = `# Рекурсивные маршруты для сервера: ${serverName}\n`;
if (server?.provider) {
code += `# Провайдер: ${server.provider}\n`;
}
if (server?.country) {
code += `# Страна: ${server.country}\n`;
}
code += '\n';
code += '# Удаление существующих рекурсивных маршрутов (опционально)\n';
code += '/ip route remove [find comment~"Recursive"]\n\n';
let validRoutesCount = 0;
gateways.forEach(gw => {
const parent = getParentGateway(gw.parentGatewayId);
if (!parent) {
code += `# Gateway "${gw.ip || 'без IP'}" - родительский gateway/интерфейс не найден, пропущен\n`;
return;
}
if (!gw.ip) {
code += `# Gateway без IP адреса - пропущен\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}`;
code += `# Рекурсивный gateway: ${gw.ip}\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
? `${gw.description} -> ${parentIp}`
: `Recursive: ${gw.ip} -> ${parentIp}`;
code += `/ip route add dst-address=${gw.ip}/32 gateway=${gatewayValue} 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\n`;
const defaultComment = gw.description ? `${gw.description} (default)` : `Recursive default: ${gw.ip}`;
code += `/ip route add dst-address=0.0.0.0/0 gateway=${gatewayValue} comment="${defaultComment}"\n`;
}
code += '\n';
});
code += '# Проверка созданных маршрутов:\n';
code += '# /ip route print where comment~"Recursive"\n';
code += `# Всего создано маршрутов: ${validRoutesCount}\n`;
if (validRoutesCount > 0) {
blocks.push({
type: 'recursive-routes',
serverName,
server,
code
});
}
});
}
// Генерируем блоки для IP адресов интерфейсов
const interfaceBlocks = generateMikrotikInterfaceAddresses();
if (interfaceBlocks && interfaceBlocks.length > 0) {
blocks.push(...interfaceBlocks);
}
return blocks;
};
// === Обработчик кнопки "Код для MikroTik" ===
const handleGenerateMikrotikCode = () => {
const code = generateMikrotikRecursiveRoutes();
setGeneratedMikrotikCode(code);
setMikrotikCodeModalOpen(true);
};
// === Копирование кода MikroTik ===
const handleCopyMikrotikCode = async (codeToCopy) => {
if (!codeToCopy) {
notify.error('Нет кода для копирования');
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(codeToCopy);
notify.success('Код скопирован в буфер обмена!');
} else {
// Fallback для старых браузеров
const textArea = document.createElement('textarea');
textArea.value = codeToCopy;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
notify.success('Код скопирован в буфер обмена!');
} catch (err) {
notify.error('Не удалось скопировать код');
}
document.body.removeChild(textArea);
}
} catch (err) {
console.error('Error copying to clipboard:', err);
notify.error('Не удалось скопировать код');
}
};
// === Копирование отдельного блока кода ===
const handleCopyBlock = async (code) => {
await handleCopyMikrotikCode(code);
};
// === Копирование всего кода ===
const handleCopyAllMikrotikCode = async () => {
const fullCode = Array.isArray(generatedMikrotikCode) && generatedMikrotikCode.length > 0
? generatedMikrotikCode.map(block => block.code).join('\n\n')
: '';
await handleCopyMikrotikCode(fullCode);
};
// === Группировка блоков по серверам ===
const groupedMikrotikCode = useMemo(() => {
if (!Array.isArray(generatedMikrotikCode) || generatedMikrotikCode.length === 0) {
return {};
}
const grouped = {};
generatedMikrotikCode.forEach(block => {
const serverName = block.serverName || '__unassigned__';
if (!grouped[serverName]) {
grouped[serverName] = {
server: block.server,
serverName: serverName,
blocks: []
};
}
grouped[serverName].blocks.push(block);
});
return grouped;
}, [generatedMikrotikCode]);
// === Получение названия типа блока ===
const getBlockTypeLabel = (type) => {
switch (type) {
case 'recursive-routes':
return 'Рекурсивные маршруты';
case 'interface-addresses':
return 'IP адреса интерфейсов';
default:
return 'Конфигурация';
}
};
// === Получение цвета провайдера ===
const getProviderColor = (provider) => {
if (!provider) return 'secondary';
// Ищем точное совпадение
const exact = GATEWAY_PROVIDERS.find(gp =>
gp.value === provider ||
gp.label.toLowerCase() === provider.toLowerCase()
);
if (exact) return exact.color;
// Проверяем частичное совпадение
const partial = GATEWAY_PROVIDERS.find(gp =>
provider.toLowerCase().includes(gp.value.toLowerCase()) ||
provider.toLowerCase().includes(gp.label.toLowerCase())
);
return partial?.color || 'secondary';
};
const getInterfaceTypeColor = (type) => {
const t = INTERFACE_TYPES.find(it => it.value === type);
return t?.color || 'secondary';
};
// === Рендер карточки сервера с его элементами ===
const renderServerGroup = (serverId, items, type) => {
const server = getServerInfo(serverId);
const isUnassigned = serverId === '__unassigned__';
return (
<div key={serverId} className="mb-4">
{/* Server header */}
<div className="d-flex align-items-center mb-2 px-1">
<div className="d-flex align-items-center">
{isUnassigned ? (
<>
<span className="avatar avatar-sm bg-secondary-lt me-2">
<IconServer size={16} className="text-muted" />
</span>
<span className="text-muted">Без привязки к серверу</span>
</>
) : (
<>
<span className="avatar avatar-sm bg-primary-lt me-2">
<IconServer size={16} className="text-primary" />
</span>
<div>
<span className="fw-medium">{server?.dns || serverId}</span>
{server && (
<span className="text-muted ms-2 small">
{server.ip}
{server.country && (
<span className="ms-1">{countryToFlag(server.country)} {server.country}</span>
)}
</span>
)}
</div>
</>
)}
</div>
<span className="badge bg-secondary-lt ms-auto">{items.length}</span>
</div>
{/* Items */}
<div className="row row-cards">
{items.map(item => (
<div key={item.id} className="col-12 col-md-6 col-lg-4">
{type === 'gateway' ? renderGatewayCard(item) : renderInterfaceCard(item, serverId)}
</div>
))}
</div>
</div>
);
};
// === Получение родительского gateway или интерфейса ===
const getParentGateway = (parentId) => {
if (!parentId) return null;
// Сначала ищем в gateway
const gateway = config.gateways?.find(g => g.id === parentId);
if (gateway) {
return {
parentType: 'gateway', // Используем parentType чтобы не конфликтовать с type интерфейса
...gateway,
ip: gateway.ip // Явно указываем IP для gateway
};
}
// Если не найден, ищем в интерфейсах
const iface = config.tunnelInterfaces?.find(i => i.id === parentId);
if (iface) {
return {
parentType: 'interface', // Используем parentType чтобы не конфликтовать с type интерфейса
...iface,
remoteIp: iface.remoteIp, // Явно указываем remoteIp для интерфейса
localIp: iface.localIp,
name: iface.name,
interfaceType: iface.type // Сохраняем тип интерфейса (GRE, WireGuard и т.д.) отдельно
};
}
// Если не найден ни gateway, ни интерфейс - логируем для отладки
console.warn('Parent gateway/interface not found:', parentId, {
availableGateways: config.gateways?.map(g => g.id),
availableInterfaces: config.tunnelInterfaces?.map(i => i.id)
});
return null;
};
// === Рендер карточки 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];
const parentGateway = gateway.type === 'recursive' && gateway.parentGatewayId
? getParentGateway(gateway.parentGatewayId)
: null;
// Определяем тип индикатора (как в ServerCard)
const indicatorType = gateway.type === 'recursive' ? 'EXIT' : 'JUMP';
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' && parentGateway) {
// Для рекурсивного: IP адрес -> IP адрес родительского gateway
const parentIp = parentGateway.parentType === 'interface'
? parentGateway.remoteIp
: parentGateway.ip;
displayText = gateway.ip && parentIp ? `${gateway.ip}${parentIp}` : '';
}
return (
<div className="card mb-2" style={{ borderRadius: '12px', maxWidth: '100%' }}>
<div className="card-body p-0">
<div className="d-flex align-items-stretch">
{/* Левая часть: тип индикатора (как в ServerCard) */}
<div
className={`d-flex align-items-center justify-content-center px-3 bg-${indicatorColor}-lt`}
style={{
minWidth: '80px',
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 }}>
{/* Верхняя строка: флаг, название, провайдер */}
<div className="d-flex align-items-center mb-1">
<div className="d-flex align-items-center me-3" style={{ minWidth: 0 }}>
{displayCountry && (
<span className="me-2" style={{ fontSize: '1.25rem' }}>{countryToFlag(displayCountry)}</span>
)}
<div style={{ minWidth: 0 }}>
<span className="fw-semibold">{gateway.description || provider || gateway.ip || '—'}</span>
{provider && (
<>
<span className="text-muted mx-2"></span>
<span className="text-muted">{provider}</span>
</>
)}
</div>
</div>
{/* Зеленый блок с маршрутом */}
{displayText && (
<span className="badge bg-success-lt text-success ms-auto" style={{ whiteSpace: 'nowrap' }}>
{displayText}
</span>
)}
</div>
{/* Нижняя строка: DNS + badges */}
<div className="d-flex align-items-center gap-2">
{server && (
<code className="text-muted" style={{ fontSize: '0.8rem' }}>
{server.dns || server.ip || gateway.serverId}
</code>
)}
<span className={`badge bg-${gatewayType.color}-lt text-${gatewayType.color}`}>
{gatewayType.label}
</span>
{gateway.ip && (
<button
className="btn btn-ghost-secondary btn-icon btn-sm p-1"
onClick={() => copyToClipboard(gateway.ip)}
title="Копировать IP"
style={{ width: '20px', height: '20px' }}
>
<IconCopy size={12} />
</button>
)}
</div>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2 px-3 border-start">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditGateway(gateway)}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteGateway(gateway)}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</div>
</div>
</div>
</div>
);
};
// === Рендер карточки Interface ===
const renderInterfaceCard = (iface, currentServerId) => {
const server = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
// Определяем, для какого сервера отображается карточка
const isForServer2 = currentServerId && iface.serverId2 && (() => {
const currentServer = getServerInfo(currentServerId);
const targetServer2 = getServerInfo(iface.serverId2);
if (!currentServer || !targetServer2) return false;
return (
currentServer.id === targetServer2.id ||
currentServer.ip === targetServer2.ip ||
currentServer.dns === targetServer2.dns ||
currentServerId === iface.serverId2 ||
currentServerId === targetServer2.id ||
currentServerId === targetServer2.ip ||
currentServerId === targetServer2.dns
);
})();
// Имя интерфейса зависит от того, для какого сервера отображается
const interfaceName = isForServer2
? (iface.name2 || iface.name || '—')
: (iface.name || '—');
// IP адреса также зависят от того, для какого сервера отображается
const displayLocalIp = isForServer2 ? (iface.remoteIp || '—') : (iface.localIp || '—');
const displayRemoteIp = isForServer2 ? (iface.localIp || '—') : (iface.remoteIp || '—');
// Определяем сервер для отображения
const displayServer = isForServer2 ? server2 : server;
const displayServerName = displayServer?.dns || displayServer?.ip || (isForServer2 ? iface.serverId2 : iface.serverId);
const displayCountry = displayServer?.country || '';
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
return (
<div className="card mb-2" style={{ borderRadius: '12px', maxWidth: '100%' }}>
<div className="card-body p-0">
<div className="d-flex align-items-stretch">
{/* Левая часть: тип интерфейса (как в ServerCard) */}
<div
className={`d-flex align-items-center justify-content-center px-3 bg-${interfaceTypeColor}-lt`}
style={{
minWidth: '80px',
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 }}>
{/* Верхняя строка: флаг, название */}
<div className="d-flex align-items-center mb-1">
<div className="d-flex align-items-center me-3" style={{ minWidth: 0 }}>
{displayCountry && (
<span className="me-2" style={{ fontSize: '1.25rem' }}>{countryToFlag(displayCountry)}</span>
)}
<div style={{ minWidth: 0 }}>
<span className="fw-semibold">{interfaceName}</span>
</div>
</div>
{/* Зеленый блок с IP */}
{displayIp && displayIp !== '—' && (
<span className="badge bg-success-lt text-success ms-auto" style={{ whiteSpace: 'nowrap' }}>
{displayIp}
</span>
)}
</div>
{/* Нижняя строка: DNS + badges */}
<div className="d-flex align-items-center gap-2">
{displayServerName && (
<code className="text-muted" style={{ fontSize: '0.8rem' }}>
{displayServerName}
</code>
)}
<span className={`badge bg-${interfaceTypeColor}-lt text-${interfaceTypeColor}`}>
{iface.type}
</span>
{displayIp && displayIp !== '—' && (
<button
className="btn btn-ghost-secondary btn-icon btn-sm p-1"
onClick={() => copyToClipboard(displayIp)}
title="Копировать IP"
style={{ width: '20px', height: '20px' }}
>
<IconCopy size={12} />
</button>
)}
</div>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2 px-3 border-start">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditInterface(iface)}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteInterface(iface)}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</div>
</div>
</div>
</div>
);
};
return (
<div className="network-config-manager">
{/* Page Header */}
<div className="page-header d-print-none mb-4">
<div className="row align-items-center">
<div className="col">
<div className="page-pretitle">Управление</div>
<h2 className="page-title d-flex align-items-center">
<IconNetwork className="me-2" size={28} />
Сетевые настройки
</h2>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
{/* Tabs as button group */}
<div className="btn-group me-2" role="group">
<button
className={`btn ${activeTab === 'gateways' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('gateways')}
>
<IconWorld size={16} className="me-1 d-none d-sm-inline" />
Gateways
<span className={`badge ms-1 ${activeTab === 'gateways' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{config.gateways?.length || 0}
</span>
</button>
<button
className={`btn ${activeTab === 'interfaces' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('interfaces')}
>
<IconRouter size={16} className="me-1 d-none d-sm-inline" />
Интерфейсы
<span className={`badge ms-1 ${activeTab === 'interfaces' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{config.tunnelInterfaces?.length || 0}
</span>
</button>
<button
className={`btn ${activeTab === 'pools' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('pools')}
>
<IconDatabase size={16} className="me-1 d-none d-sm-inline" />
IP пулы
<span className={`badge ms-1 ${activeTab === 'pools' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{config.ipPools?.length || 0}
</span>
</button>
</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') && uniqueServersInConfig.length > 0 && (
<div className="col-6 col-md-3 col-lg-2">
<select
className="form-select"
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
>
<option value="">Все серверы</option>
{uniqueServersInConfig.map(s => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
)}
{/* Provider filter for gateways - берем уникальные провайдеры из серверов */}
{activeTab === 'gateways' && (
<div className="col-6 col-md-3 col-lg-2">
<select
className="form-select"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
>
<option value="">Все провайдеры</option>
{Array.from(new Set(servers.map(s => s.provider).filter(Boolean))).map(provider => (
<option key={provider} value={provider}>{provider}</option>
))}
</select>
</div>
)}
{/* Type filter for interfaces */}
{activeTab === 'interfaces' && (
<div className="col-6 col-md-3 col-lg-2">
<select
className="form-select"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="">Все типы</option>
{INTERFACE_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
)}
{/* Reset filters */}
{hasActiveFilters && (
<div className="col-auto">
<button className="btn btn-ghost-secondary btn-sm" onClick={resetFilters}>
<IconX size={14} className="me-1" />
Сбросить
</button>
</div>
)}
<div className="col-auto ms-auto d-flex gap-2">
{/* View mode toggle */}
{(activeTab === 'gateways' || activeTab === 'interfaces') && (
<div className="btn-group btn-group-sm" role="group">
<button
className={`btn ${viewMode === 'cards' ? 'btn-secondary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('cards')}
title="Карточки"
>
<IconLayoutGrid size={16} />
</button>
<button
className={`btn ${viewMode === 'table' ? 'btn-secondary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('table')}
title="Таблица"
>
<IconList size={16} />
</button>
</div>
)}
{/* Add button */}
{activeTab === 'gateways' && (
<button className="btn btn-primary btn-sm" onClick={handleAddGateway}>
<IconPlus size={16} className="me-1" />
Gateway
</button>
)}
{activeTab === 'interfaces' && (
<button className="btn btn-primary btn-sm" onClick={handleAddInterface}>
<IconPlus size={16} className="me-1" />
Интерфейс
</button>
)}
{activeTab === 'pools' && (
<button className="btn btn-primary btn-sm" onClick={handleAddPool}>
<IconPlus size={16} className="me-1" />
IP пул
</button>
)}
</div>
</div>
</div>
</div>
{/* Content */}
{loading ? (
<div className="card">
<div className="card-body text-center py-5">
<div className="spinner-border text-primary" role="status"></div>
<div className="text-muted mt-2">Загрузка...</div>
</div>
</div>
) : (
<>
{/* Gateways Tab */}
{activeTab === 'gateways' && (
<>
{filteredGateways.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-3">
<span className="avatar avatar-xl bg-primary-lt">
<IconWorld size={40} className="text-primary" />
</span>
</div>
<h3>Gateways не найдены</h3>
<p className="text-muted">
{hasActiveFilters
? 'Попробуйте изменить параметры фильтрации'
: 'Добавьте gateway для использования в конфигурациях серверов'
}
</p>
{!hasActiveFilters && (
<button className="btn btn-primary" onClick={handleAddGateway}>
<IconPlus size={16} className="me-1" />
Добавить Gateway
</button>
)}
</div>
</div>
) : viewMode === 'cards' ? (
// Grouped cards view
<div>
{Object.entries(gatewaysByServer).map(([serverId, gateways]) =>
renderServerGroup(serverId, gateways, 'gateway')
)}
</div>
) : (
// Table view
<div className="card">
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>IP адрес</th>
<th>Тип</th>
<th>Провайдер</th>
<th>Сервер</th>
<th>Страна</th>
<th className="text-end" style={{ width: 100 }}>Действия</th>
</tr>
</thead>
<tbody>
{filteredGateways.map(gateway => {
const server = getServerInfo(gateway.serverId);
const provider = server?.provider || '';
const serverCountry = server?.country || '';
const gatewayCountry = gateway.country || '';
const displayCountry = gatewayCountry || serverCountry;
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
const parentGateway = gateway.type === 'recursive' && gateway.parentGatewayId
? getParentGateway(gateway.parentGatewayId)
: 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>
{parentGateway && (
<div className="text-muted small mt-1">
{parentGateway.parentType === 'interface'
? `Интерфейс ${parentGateway.name || parentGateway.interfaceType || 'interface'} (${parentGateway.remoteIp || '—'})`
: parentGateway.ip
? `Gateway ${parentGateway.ip}`
: 'Gateway (не указан IP)'
}
</div>
)}
</td>
<td>
{provider ? (
<span className={`badge bg-${getProviderColor(provider)}-lt text-${getProviderColor(provider)}`}>
{provider}
</span>
) : '—'}
</td>
<td>
{server ? (
<div className="d-flex align-items-center">
<IconServer size={14} className="text-muted me-1" />
<span>{server.dns || server.ip}</span>
</div>
) : gateway.serverId ? (
<span className="text-muted">{gateway.serverId}</span>
) : (
<span className="text-muted"></span>
)}
</td>
<td>
{displayCountry ? (
<div>
<span>{countryToFlag(displayCountry)} {displayCountry}</span>
{gatewayCountry && serverCountry && gatewayCountry !== serverCountry && (
<div className="text-muted small" style={{ fontSize: '0.7rem' }}>
сервер: {serverCountry}
</div>
)}
</div>
) : '—'}
</td>
<td className="text-end">
<div className="btn-list gap-1 mb-0 justify-content-end">
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => handleEditGateway(gateway)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-ghost-danger btn-icon btn-sm"
onClick={() => handleDeleteGateway(gateway)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* Interfaces Tab */}
{activeTab === 'interfaces' && (
<>
{filteredInterfaces.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-3">
<span className="avatar avatar-xl bg-blue-lt">
<IconRouter size={40} className="text-blue" />
</span>
</div>
<h3>Интерфейсы не найдены</h3>
<p className="text-muted">
{hasActiveFilters
? 'Попробуйте изменить параметры фильтрации'
: 'Добавьте туннельные интерфейсы для серверов'
}
</p>
{!hasActiveFilters && (
<button className="btn btn-primary" onClick={handleAddInterface}>
<IconPlus size={16} className="me-1" />
Добавить интерфейс
</button>
)}
</div>
</div>
) : viewMode === 'cards' ? (
// Grouped cards view
<div>
{Object.entries(interfacesByServer).map(([serverId, interfaces]) =>
renderServerGroup(serverId, interfaces, 'interface')
)}
</div>
) : (
// Table view
<div className="card">
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>Имя</th>
<th>Тип</th>
<th>Local IP</th>
<th>Remote IP</th>
<th>Серверы</th>
<th className="text-end" style={{ width: 100 }}>Действия</th>
</tr>
</thead>
<tbody>
{filteredInterfaces.map(iface => {
const server = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
return (
<tr key={iface.id}>
<td><strong>{iface.name || '—'}</strong></td>
<td>
<span className={`badge bg-${getInterfaceTypeColor(iface.type)}-lt text-${getInterfaceTypeColor(iface.type)}`}>
{iface.type}
</span>
</td>
<td>
<code className="d-flex align-items-center gap-1">
{iface.localIp || '—'}
{iface.localIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.localIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</code>
</td>
<td>
<code className="d-flex align-items-center gap-1">
{iface.remoteIp || '—'}
{iface.remoteIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.remoteIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</code>
</td>
<td>
<div>
{server ? (
<div className="d-flex align-items-center">
<IconServer size={14} className="text-muted me-1" />
<span>{server.dns || server.ip}</span>
</div>
) : iface.serverId ? (
<span className="text-muted">{iface.serverId}</span>
) : (
<span className="text-muted"></span>
)}
{server2 && (
<div className="d-flex align-items-center mt-1">
<IconServer size={14} className="text-muted me-1" />
<span className="text-muted small">{server2.dns || server2.ip}</span>
</div>
)}
</div>
</td>
<td className="text-end">
<div className="btn-list gap-1 mb-0 justify-content-end">
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => handleEditInterface(iface)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-ghost-danger btn-icon btn-sm"
onClick={() => handleDeleteInterface(iface)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* IP Pools Tab */}
{activeTab === 'pools' && (
<>
{filteredPools.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-3">
<span className="avatar avatar-xl bg-green-lt">
<IconDatabase size={40} className="text-green" />
</span>
</div>
<h3>IP пулы не найдены</h3>
<p className="text-muted">
{hasActiveFilters
? 'Попробуйте изменить параметры фильтрации'
: 'Добавьте пулы IP-адресов для организации сетей'
}
</p>
{!hasActiveFilters && (
<button className="btn btn-primary" onClick={handleAddPool}>
<IconPlus size={16} className="me-1" />
Добавить IP пул
</button>
)}
</div>
</div>
) : (
<div className="row row-cards">
{filteredPools.map(pool => (
<div key={pool.id} className="col-sm-6 col-lg-4">
<div className="card">
<div className="card-body">
<div className="d-flex align-items-center mb-3">
<span className="avatar avatar-sm bg-green-lt me-2">
<IconDatabase size={16} className="text-green" />
</span>
<div className="flex-fill">
<div className="fw-medium">{pool.name || '—'}</div>
</div>
<div className="dropdown">
<button className="btn btn-ghost-secondary btn-icon btn-sm" data-bs-toggle="dropdown">
<IconEdit size={16} />
</button>
<div className="dropdown-menu dropdown-menu-end">
<button className="dropdown-item" onClick={() => handleEditPool(pool)}>
<IconEdit size={16} className="me-2" />Редактировать
</button>
<button className="dropdown-item text-danger" onClick={() => handleDeletePool(pool)}>
<IconTrash size={16} className="me-2" />Удалить
</button>
</div>
</div>
</div>
<div className="d-flex align-items-center gap-2 mb-2">
<code className="flex-fill">{pool.cidr || '—'}</code>
{pool.cidr && (
<button
className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={() => copyToClipboard(pool.cidr)}
title="Копировать CIDR"
>
<IconCopy size={14} />
</button>
)}
</div>
{pool.description && (
<div className="text-muted small">{pool.description}</div>
)}
</div>
</div>
</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, parentGatewayId: val === 'direct' ? '' : editingGateway.parentGatewayId })}
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>
<GatewayAutocompleteInput
value={editingGateway.parentGatewayId}
onChange={(val) => setEditingGateway({ ...editingGateway, parentGatewayId: val })}
gateways={config.gateways}
interfaces={config.tunnelInterfaces}
excludeGatewayId={editingGateway.id}
placeholder="Выберите родительский gateway или интерфейс..."
/>
<div className="form-text">
Рекурсивный gateway может ссылаться на прямой gateway или на интерфейс (по его remote IP)
</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 })}
options={INTERFACE_TYPES}
/>
</div>
<div className="col-md-4">
<FormField
label="Local IP"
name="localIp"
value={editingInterface.localIp}
onChange={(val) => setEditingInterface({ ...editingInterface, localIp: val })}
placeholder="10.10.0.1"
/>
</div>
<div className="col-md-4">
<FormField
label="Remote IP"
name="remoteIp"
value={editingInterface.remoteIp}
onChange={(val) => setEditingInterface({ ...editingInterface, remoteIp: val })}
placeholder="10.10.0.2"
/>
</div>
<div className="col-12">
<label className="form-label">Второй сервер (опционально)</label>
<ServerAutocompleteInput
value={editingInterface.serverId2 || ''}
onChange={(val) => setEditingInterface({ ...editingInterface, serverId2: val })}
servers={servers.filter(s => s.id !== editingInterface.serverId && s.ip !== editingInterface.serverId)}
placeholder="Выберите второй сервер (для понимания связи между серверами)..."
/>
<div className="form-text">
Укажите второй сервер, если интерфейс связывает два сервера
</div>
</div>
{editingInterface.serverId && (() => {
const server = getServerInfo(editingInterface.serverId);
return server ? (
<div className="col-12">
<div className="card card-sm border">
<div className="card-body p-2">
<div className="d-flex align-items-center">
<IconServer size={16} className="text-muted me-2" />
<span className="fw-medium text-muted me-2">Сервер 1:</span>
<span className="me-2">{server.dns || server.ip}</span>
{server.provider && (
<>
<span className="text-muted me-2"></span>
<span className="me-2">{server.provider}</span>
</>
)}
{server.country && (
<>
<span className="text-muted me-2"></span>
<span>{countryToFlag(server.country)}</span>
<span className="ms-1">{server.country}</span>
</>
)}
</div>
</div>
</div>
</div>
) : null;
})()}
{editingInterface.serverId2 && (() => {
const server2 = getServerInfo(editingInterface.serverId2);
return server2 ? (
<div className="col-12">
<div className="card card-sm border">
<div className="card-body p-2">
<div className="d-flex align-items-center">
<IconServer size={16} className="text-muted me-2" />
<span className="fw-medium text-muted me-2">Сервер 2:</span>
<span className="me-2">{server2.dns || server2.ip}</span>
{server2.provider && (
<>
<span className="text-muted me-2"></span>
<span className="me-2">{server2.provider}</span>
</>
)}
{server2.country && (
<>
<span className="text-muted me-2"></span>
<span>{countryToFlag(server2.country)}</span>
<span className="ms-1">{server2.country}</span>
</>
)}
</div>
</div>
</div>
</div>
) : null;
})()}
</div>
)}
</FormModal>
{/* Pool Modal */}
<FormModal
show={poolModalOpen}
onClose={() => { setPoolModalOpen(false); setEditingPool(null); }}
onSubmit={() => editingPool && handleSavePool(editingPool)}
title={poolModalMode === 'add' ? 'Добавить IP пул' : 'Редактировать IP пул'}
submitLabel={poolModalMode === 'add' ? 'Добавить' : 'Сохранить'}
submitIcon={poolModalMode === 'add' ? IconPlus : IconEdit}
>
{editingPool && (
<div className="row g-3">
<div className="col-md-6">
<FormField
label="Название"
name="name"
value={editingPool.name}
onChange={(val) => setEditingPool({ ...editingPool, name: val })}
placeholder="GRE Tunnels"
required
/>
</div>
<div className="col-md-6">
<FormField
label="CIDR"
name="cidr"
value={editingPool.cidr}
onChange={(val) => setEditingPool({ ...editingPool, cidr: val })}
placeholder="10.10.0.0/24"
required
/>
</div>
<div className="col-12">
<FormField
label="Описание"
name="description"
value={editingPool.description}
onChange={(val) => setEditingPool({ ...editingPool, description: val })}
placeholder="Пул для GRE туннелей между серверами"
/>
</div>
</div>
)}
</FormModal>
{/* Delete Confirmation Modal */}
<ConfirmModal
show={deleteModalOpen}
onClose={() => { setDeleteModalOpen(false); setItemToDelete(null); }}
onConfirm={executeDelete}
title="Подтверждение удаления"
message={
itemToDelete ? (
<>
<p>Вы уверены, что хотите удалить <strong>{itemToDelete.ip || itemToDelete.name || itemToDelete.cidr || 'этот элемент'}</strong>?</p>
<p className="text-muted mb-0">Это действие нельзя отменить.</p>
</>
) : null
}
confirmLabel="Удалить"
variant="danger"
icon={IconTrash}
/>
{/* MikroTik Code Modal */}
{mikrotikCodeModalOpen && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-xl">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
Предварительный просмотр конфигурации MikroTik
</h5>
<button type="button" className="btn-close" onClick={() => setMikrotikCodeModalOpen(false)}></button>
</div>
<div className="modal-body">
<div className="alert alert-info mb-3">
Конфигурация MikroTik RouterOS для рекурсивных маршрутов и IP адресов интерфейсов
</div>
{Object.keys(groupedMikrotikCode).length > 0 ? (
<div className="d-flex flex-column gap-4">
{Object.entries(groupedMikrotikCode)
.sort(([a], [b]) => a.localeCompare(b))
.map(([serverName, serverData]) => {
const server = serverData.server;
const isUnassigned = serverName === '__unassigned__';
return (
<div key={serverName} className="border rounded p-3">
{/* Заголовок сервера */}
<div className="d-flex align-items-center mb-3 pb-2 border-bottom">
<div className="d-flex align-items-center flex-fill">
{isUnassigned ? (
<>
<span className="avatar avatar-sm bg-secondary-lt me-2">
<IconServer size={16} className="text-muted" />
</span>
<span className="text-muted">Без привязки к серверу</span>
</>
) : (
<>
<span className="avatar avatar-sm bg-primary-lt me-2">
<IconServer size={16} className="text-primary" />
</span>
<div>
<div className="fw-bold">{serverName}</div>
{server && (
<div className="text-muted small">
{server.provider && <span>{server.provider}</span>}
{server.provider && server.country && <span className="mx-1"></span>}
{server.country && (
<span>{countryToFlag(server.country)} {server.country}</span>
)}
{server.ip && server.ip !== serverName && (
<>
{(server.provider || server.country) && <span className="mx-1"></span>}
<span>{server.ip}</span>
</>
)}
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Блоки кода для этого сервера */}
<div className="d-flex flex-column gap-3">
{serverData.blocks.map((block, blockIndex) => {
const blockTitle = block.type === 'recursive-routes'
? 'Рекурсивные маршруты'
: block.type === 'interface-addresses'
? 'IP адреса интерфейсов'
: 'Конфигурация';
return (
<div key={blockIndex} className="border rounded overflow-hidden">
{/* Заголовок блока */}
<div className="bg-secondary-lt px-3 py-2 d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center">
<IconCode size={18} className="text-secondary me-2" />
<span className="fw-medium">{blockTitle}</span>
</div>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => handleCopyBlock(block.code)}
title="Копировать этот блок"
>
<IconCopy size={16} className="me-1" />
Копировать
</button>
</div>
{/* Код блока */}
<div className="bg-dark text-white p-3">
<pre className="mb-0" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '0.875rem' }}>
<code className="text-white">{block.code}</code>
</pre>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="alert alert-warning mb-0">
Нет данных для генерации конфигурации
</div>
)}
<div className="mt-3 text-muted small">
<p className="mb-1"><strong>Инструкция по применению:</strong></p>
<ol className="mb-0">
<li>Скопируйте конфигурацию в буфер обмена</li>
<li>Подключитесь к MikroTik через SSH или Winbox</li>
<li>Вставьте конфигурацию в терминал</li>
<li>Проверьте применение командой: <code>/ip route print</code> или <code>/ip address print</code></li>
</ol>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setMikrotikCodeModalOpen(false)}>
Закрыть
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleCopyAllMikrotikCode}
disabled={!Array.isArray(generatedMikrotikCode) || generatedMikrotikCode.length === 0}
>
<IconCopy className="me-2" />
Копировать всё
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default NetworkConfigManager;