import { useState, useEffect, useRef } from 'react'; import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin, IconRouter, IconPlug } from '@tabler/icons-react'; import api from '../../lib/api.js'; import { makeGateway, normalizeGateways, needsGateways as NEEDS_GATEWAYS, needsTunnel as NEEDS_TUNNEL, SERVER_TYPE_OPTIONS, TUNNEL_TYPES } from '../../utils/serverUtils.js'; /** * Список провайдеров */ 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 initializedRef = useRef(false); const [localServer, setLocalServer] = useState(getEmptyServer()); const [customProvider, setCustomProvider] = useState(''); const [internalError, setInternalError] = useState(''); const [mikrotikTesting, setMikrotikTesting] = useState(false); const [mikrotikTestResult, setMikrotikTestResult] = useState(null); 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; setMikrotikTestResult(null); } }, [show, server, isEditMode]); if (!show) return null; /** * Обработчик изменения поля */ const handleFieldChange = (field, value) => { setLocalServer(prev => ({ ...prev, [field]: value })); }; /** * Валидация и сохранение */ 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; } // Шлюзы для jumphost/exit задаются в другом месте (network-config); при сохранении передаём существующие или один по умолчанию const needsGws = NEEDS_GATEWAYS(localServer.type); let finalGateways = []; if (needsGws) { finalGateways = normalizeGateways(localServer.gateways, localServer.gateway || localServer.dns || localServer.ip); if (finalGateways.length === 0) { finalGateways = [makeGateway({ primary: true, name: localServer.dns?.split('.')[0] || 'main' })]; } } const serverToSave = { ...localServer, provider: finalProvider, gateways: finalGateways, }; onSave && onSave(serverToSave); }; 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" />
{/* Страна — 2-буквенный код как в интерфейсах */}
handleFieldChange('country', e.target.value.toUpperCase().slice(0, 2))} placeholder="RU, SE, DE…" maxLength={2} required />
2-буквенный код страны (ISO 3166-1 alpha-2)
{/* Провайдер */}
{/* Кастомный провайдер */} {localServer.provider === 'Другой' && (
setCustomProvider(e.target.value)} placeholder="Введите название провайдера" required />
)} {/* Тип сервера */}
{/* Тип туннеля - только для типов, которым он нужен */} {NEEDS_TUNNEL(localServer.type) && (
)} {/* Группа объединения — для домашнего роутера с мультиваном: один ключ у нескольких записей = одна карточка в списке */} {localServer.type === 'home' && (
{ const v = e.target.value.trim(); handleFieldChange('groupKey', v || ''); }} placeholder="Например: home-multivan — две записи с одним ключом отобразятся как один роутер" />
Укажите одинаковый ключ у обоих WAN-записей одного роутера, чтобы в разделе «Серверы» они отображались одной карточкой. ID и привязки (интерфейсы, фильтры) не меняются.
)}
{/* MikroTik RouterOS API — для jumphost и домашних (входных) роутеров */} {(localServer.type === 'jumphost' || localServer.type === 'home') && (
handleFieldChange('mikrotikHost', e.target.value)} placeholder={localServer.ip || 'IP или DNS'} />
handleFieldChange('mikrotikPort', e.target.value)} placeholder="80" />
handleFieldChange('mikrotikUser', e.target.value)} placeholder="admin" />
handleFieldChange('mikrotikPassword', e.target.value)} placeholder={localServer.hasMikrotikPassword ? '••••••' : 'Введите пароль'} />
{mikrotikTestResult && ( {mikrotikTestResult.ok ? '✓ ' + (mikrotikTestResult.message || 'OK') : '✗ ' + mikrotikTestResult.message} )}
)}
); } export default ServerModal;