Files
router-lists-ui/frontend/src/components/server/ServerModal.jsx
T

591 lines
23 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, 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 (
<div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center">
{isEditMode ? (
<>
<IconEdit className="icon me-2" />
Редактировать сервер
</>
) : (
<>
<IconPlus className="icon me-2" />
Добавить новый сервер
</>
)}
</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && (
<div className="alert alert-danger mb-3" role="alert">
{error}
</div>
)}
<div className="row g-3">
{/* ID - опционально */}
<div className="col-md-4">
<label className="form-label">
ID <span className="text-muted">(опционально)</span>
</label>
<input
type="text"
className="form-control"
value={localServer.id || ''}
onChange={(e) => handleFieldChange('id', e.target.value)}
placeholder="srv-1"
/>
</div>
{/* IP адрес */}
<div className="col-md-4">
<label className="form-label required">IP адрес</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconServer size={16} />
</span>
<input
type="text"
className="form-control"
value={localServer.ip || ''}
onChange={(e) => handleFieldChange('ip', e.target.value)}
placeholder="192.168.1.1"
required
/>
</div>
</div>
{/* DNS */}
<div className="col-md-4">
<label className="form-label required">DNS имя</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconWorld size={16} />
</span>
<input
type="text"
className="form-control"
value={localServer.dns || ''}
onChange={(e) => handleFieldChange('dns', e.target.value)}
placeholder="srv1.example.com"
required
/>
</div>
</div>
{/* Внешний IP */}
<div className="col-md-6">
<label className="form-label">
Внешний IP <span className="text-muted">(опционально)</span>
</label>
<input
type="text"
className="form-control"
value={localServer.extIp || ''}
onChange={(e) => handleFieldChange('extIp', e.target.value)}
placeholder="Публичный IP, если отличается"
/>
</div>
{/* Внутренний IP */}
<div className="col-md-6">
<label className="form-label">
Внутренний IP <span className="text-muted">(опционально)</span>
</label>
<input
type="text"
className="form-control"
value={localServer.internalIp || ''}
onChange={(e) => handleFieldChange('internalIp', e.target.value)}
placeholder="10.x.x.x / 192.168.x.x"
/>
</div>
{/* Страна */}
<div className="col-md-6">
<label className="form-label required">Страна</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconMapPin size={16} />
</span>
<select
className="form-select ps-8"
value={localServer.country || ''}
onChange={(e) => handleFieldChange('country', e.target.value)}
required
>
{COUNTRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* Провайдер */}
<div className="col-md-6">
<label className="form-label required">Провайдер</label>
<select
className="form-select"
value={localServer.provider || ''}
onChange={(e) => handleFieldChange('provider', e.target.value)}
required
>
{PROVIDER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
</div>
{/* Кастомный провайдер */}
{localServer.provider === 'Другой' && (
<div className="col-12">
<label className="form-label required">Название провайдера</label>
<input
type="text"
className="form-control"
value={customProvider}
onChange={(e) => setCustomProvider(e.target.value)}
placeholder="Введите название провайдера"
required
/>
</div>
)}
{/* Тип сервера */}
<div className={NEEDS_TUNNEL(localServer.type) ? "col-md-6" : "col-12"}>
<label className="form-label required">Тип сервера</label>
<select
className="form-select"
value={localServer.type || 'jumphost'}
onChange={(e) => handleFieldChange('type', e.target.value)}
required
>
{SERVER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Тип туннеля - только для типов, которым он нужен */}
{NEEDS_TUNNEL(localServer.type) && (
<div className="col-md-6">
<label className="form-label required">Тип туннеля</label>
<select
className="form-select"
value={localServer.tunnel || 'GRE'}
onChange={(e) => handleFieldChange('tunnel', e.target.value)}
required
>
{TUNNEL_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
)}
{/* Основной шлюз (текстовое поле для совместимости) */}
<div className="col-12">
<label className="form-label">
Основной шлюз <span className="text-muted">(устаревшее поле)</span>
</label>
<input
type="text"
className="form-control"
value={localServer.gateway || ''}
onChange={(e) => handleFieldChange('gateway', e.target.value)}
placeholder="Cloudflare, Hetzner и т.д."
/>
<div className="form-text">
Используйте секцию "Шлюзы" ниже для более детальной настройки
</div>
</div>
</div>
{/* Секция шлюзов */}
{showGateways && (
<div className="mt-4">
<div className="d-flex align-items-center justify-content-between mb-2">
<label className="form-label mb-0">
Шлюзы ({currentGateways.length})
</label>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={handleAddGateway}
>
<IconPlus size={14} className="me-1" />
Добавить шлюз
</button>
</div>
<div className="list-group list-group-flush border rounded">
{currentGateways.map((gw, idx) => (
<div key={gw.id || `gw-${idx}`} className="list-group-item">
<div className="row g-2 align-items-end">
<div className="col-12 col-md-3">
<label className="form-label small mb-1">Имя *</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.name || ''}
onChange={(e) => handleGatewayChange(idx, 'name', e.target.value)}
placeholder="GW-MSK"
required
/>
</div>
<div className="col-12 col-md-3">
<label className="form-label small mb-1">IP</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.ip || ''}
onChange={(e) => handleGatewayChange(idx, 'ip', e.target.value)}
placeholder="10.0.0.1"
/>
</div>
<div className="col-12 col-md-3">
<label className="form-label small mb-1">Комментарий</label>
<input
type="text"
className="form-control form-control-sm"
value={gw.comment || ''}
onChange={(e) => handleGatewayChange(idx, 'comment', e.target.value)}
placeholder="Описание"
/>
</div>
<div className="col-6 col-md-2">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={!!gw.primary}
onChange={(e) => handlePrimaryChange(idx, e.target.checked)}
id={`primary-${gw.id || idx}`}
/>
<label className="form-check-label small" htmlFor={`primary-${gw.id || idx}`}>
Основной
</label>
</div>
</div>
<div className="col-6 col-md-1 text-end">
{currentGateways.length > 1 && (
<button
type="button"
className="btn btn-ghost-danger btn-icon btn-sm"
onClick={() => handleRemoveGateway(idx)}
title="Удалить шлюз"
>
<IconTrash size={14} />
</button>
)}
</div>
</div>
<div className="text-muted small mt-1">
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
</div>
</div>
))}
</div>
<div className="form-text mt-2">
Для типов jumphost/exit должен быть минимум один шлюз с признаком "Основной"
</div>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">
Отмена
</button>
<button type="submit" className="btn btn-primary d-flex align-items-center">
{isEditMode ? (
<>
<IconEdit size={16} className="me-1" />
Сохранить
</>
) : (
<>
<IconPlus size={16} className="me-1" />
Добавить сервер
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
export default ServerModal;