Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
2234 lines
95 KiB
React
2234 lines
95 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 { 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;
|