import { useState, useEffect, useRef } from 'react'; import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin } from '@tabler/icons-react'; import { makeGateway, normalizeGateways, needsGateways as NEEDS_GATEWAYS, needsTunnel as NEEDS_TUNNEL, SERVER_TYPE_OPTIONS, TUNNEL_TYPES } from '../../utils/serverUtils.js'; /** * Списки стран и провайдеров */ const COUNTRY_OPTIONS = [ { value: '', label: 'Выберите страну', disabled: true }, { value: 'RU', label: 'Россия (RU)' }, { value: 'SWE', label: 'Швеция (SWE)' }, { value: 'FI', label: 'Финляндия (FI)' }, { value: 'US', label: 'США (US)' }, { value: 'DE', label: 'Германия (DE)' }, { value: 'NL', label: 'Нидерланды (NL)' }, { value: 'SE', label: 'Швеция (SE)' }, { value: 'SG', label: 'Сингапур (SG)' }, ]; const PROVIDER_OPTIONS = [ { value: '', label: 'Выберите провайдера', disabled: true }, { value: 'Yandex', label: 'Yandex Cloud' }, { value: 'Hetzner', label: 'Hetzner' }, { value: 'VPSVILLE', label: 'VPSVILLE' }, { value: 'HOSTVDS', label: 'HOSTVDS' }, { value: 'HIP.hosting', label: 'HIP.hosting' }, { value: 'Ihor-hosting', label: 'Ihor-hosting' }, { value: 'DigitalOcean', label: 'DigitalOcean' }, { value: 'Vultr', label: 'Vultr' }, { value: 'Linode', label: 'Linode' }, { value: 'OVH', label: 'OVH' }, { value: 'AWS', label: 'Amazon Web Services' }, { value: 'Google', label: 'Google Cloud' }, { value: 'Azure', label: 'Microsoft Azure' }, { value: 'Другой', label: 'Другой...' }, ]; /** * Создаёт пустой объект сервера */ const getEmptyServer = () => ({ id: '', ip: '', extIp: '', internalIp: '', dns: '', country: '', provider: '', tunnel: 'GRE', type: 'jumphost', gateway: '', gateways: [makeGateway({ primary: true })], }); /** * Универсальное модальное окно для добавления/редактирования сервера * * @param {Object} props * @param {boolean} props.show - Показывать ли модальное окно * @param {'add'|'edit'} props.mode - Режим работы: 'add' для добавления, 'edit' для редактирования * @param {Object} props.server - Данные сервера (для режима редактирования) * @param {Function} props.onSave - Callback при сохранении (получает объект сервера) * @param {Function} props.onClose - Callback при закрытии * @param {string} props.error - Текст ошибки для отображения */ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: externalError }) { const modalRef = useRef(null); const initializedRef = useRef(false); const [localServer, setLocalServer] = useState(getEmptyServer()); const [customProvider, setCustomProvider] = useState(''); const [internalError, setInternalError] = useState(''); const isEditMode = mode === 'edit'; const error = externalError || internalError; /** * Подготовка сервера с нормализованными gateways */ const ensureGateways = (srv) => { if (!srv) return getEmptyServer(); const needs = NEEDS_GATEWAYS(srv.type); let gateways = []; if (needs) { gateways = normalizeGateways(srv.gateways, srv.gateway || srv.dns || srv.ip); if (gateways.length === 0) { gateways = [makeGateway({ primary: true })]; } } return { ...srv, gateways }; }; /** * Инициализация данных при открытии */ useEffect(() => { if (show && !initializedRef.current) { if (isEditMode && server) { const prepared = ensureGateways(server); setLocalServer(prepared); // Проверяем, нужен ли кастомный провайдер const providerExists = PROVIDER_OPTIONS.some(p => p.value === server.provider); if (!providerExists && server.provider) { setCustomProvider(server.provider); setLocalServer(prev => ({ ...prev, provider: 'Другой' })); } } else { setLocalServer(getEmptyServer()); setCustomProvider(''); } setInternalError(''); initializedRef.current = true; } if (!show) { initializedRef.current = false; } }, [show, server, isEditMode]); /** * Управление Bootstrap modal */ useEffect(() => { if (window.Tabler && window.Tabler.Modal && modalRef.current) { const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); if (show) { modalInstance.show(); } else { modalInstance.hide(); } const handler = () => onClose && onClose(); modalRef.current.addEventListener('hide.bs.modal', handler); return () => { if (modalRef.current) { modalRef.current.removeEventListener('hide.bs.modal', handler); } }; } }, [show, onClose]); /** * Обработчик изменения поля */ const handleFieldChange = (field, value) => { setLocalServer(prev => ({ ...prev, [field]: value })); }; /** * Обработчики для gateways */ const handleGatewayChange = (idx, field, value) => { setLocalServer(prev => { const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : []; const updatedGateways = currentGateways.map((gw, i) => i === idx ? { ...gw, [field]: value } : gw ); return { ...prev, gateways: updatedGateways }; }); }; const handlePrimaryChange = (idx, checked) => { setLocalServer(prev => { const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : []; const updatedGateways = currentGateways.map((gw, i) => ({ ...gw, primary: i === idx ? checked : false })); if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) { updatedGateways[idx].primary = true; } return { ...prev, gateways: updatedGateways }; }); }; const handleAddGateway = () => { setLocalServer(prev => { const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : []; const newGw = makeGateway({ primary: currentGateways.length === 0 }); return { ...prev, gateways: [...currentGateways, newGw] }; }); }; const handleRemoveGateway = (idx) => { setLocalServer(prev => { const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : []; if (currentGateways.length <= 1) return prev; const updatedGateways = currentGateways.filter((_, i) => i !== idx); if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) { updatedGateways[0].primary = true; } return { ...prev, gateways: updatedGateways }; }); }; /** * Валидация и сохранение */ const handleSubmit = (e) => { e.preventDefault(); setInternalError(''); // Валидация обязательных полей if (!localServer.ip?.trim()) { setInternalError('IP адрес обязателен'); return; } if (!localServer.dns?.trim()) { setInternalError('DNS имя обязательно'); return; } if (!localServer.country?.trim()) { setInternalError('Страна обязательна'); return; } // Провайдер let finalProvider = localServer.provider; if (localServer.provider === 'Другой') { if (!customProvider.trim()) { setInternalError('Введите название провайдера'); return; } finalProvider = customProvider.trim(); } if (!finalProvider) { setInternalError('Провайдер обязателен'); return; } // Валидация gateways для jumphost/exit const needsGws = NEEDS_GATEWAYS(localServer.type); let finalGateways = []; if (needsGws) { finalGateways = normalizeGateways(localServer.gateways, localServer.gateway || localServer.dns || localServer.ip); if (finalGateways.length === 0) { setInternalError('Добавьте хотя бы один шлюз'); return; } } const serverToSave = { ...localServer, provider: finalProvider, gateways: finalGateways, }; onSave && onSave(serverToSave); }; const currentGateways = Array.isArray(localServer.gateways) ? localServer.gateways : []; const showGateways = NEEDS_GATEWAYS(localServer.type); return (
{isEditMode ? ( <> Редактировать сервер ) : ( <> Добавить новый сервер )}
{error && (
{error}
)}
{/* ID - опционально */}
handleFieldChange('id', e.target.value)} placeholder="srv-1" />
{/* IP адрес */}
handleFieldChange('ip', e.target.value)} placeholder="192.168.1.1" required />
{/* DNS */}
handleFieldChange('dns', e.target.value)} placeholder="srv1.example.com" required />
{/* Внешний IP */}
handleFieldChange('extIp', e.target.value)} placeholder="Публичный IP, если отличается" />
{/* Внутренний IP */}
handleFieldChange('internalIp', e.target.value)} placeholder="10.x.x.x / 192.168.x.x" />
{/* Страна */}
{/* Провайдер */}
{/* Кастомный провайдер */} {localServer.provider === 'Другой' && (
setCustomProvider(e.target.value)} placeholder="Введите название провайдера" required />
)} {/* Тип сервера */}
{/* Тип туннеля - только для типов, которым он нужен */} {NEEDS_TUNNEL(localServer.type) && (
)} {/* Основной шлюз (текстовое поле для совместимости) */}
handleFieldChange('gateway', e.target.value)} placeholder="Cloudflare, Hetzner и т.д." />
Используйте секцию "Шлюзы" ниже для более детальной настройки
{/* Секция шлюзов */} {showGateways && (
{currentGateways.map((gw, idx) => (
handleGatewayChange(idx, 'name', e.target.value)} placeholder="GW-MSK" required />
handleGatewayChange(idx, 'ip', e.target.value)} placeholder="10.0.0.1" />
handleGatewayChange(idx, 'comment', e.target.value)} placeholder="Описание" />
handlePrimaryChange(idx, e.target.checked)} id={`primary-${gw.id || idx}`} />
{currentGateways.length > 1 && ( )}
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
))}
Для типов jumphost/exit должен быть минимум один шлюз с признаком "Основной"
)}
); } export default ServerModal;