Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
591 lines
23 KiB
React
591 lines
23 KiB
React
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;
|