Files
router-lists-ui/frontend/src/components/server/ServerModal.jsx
T
denozord 58ee09b70e
Publish Docker image / build-and-push (push) Successful in 2m24s
Update frontend dependencies, enhance Vite configuration, and refactor components
- Added new dependencies including @base-ui/react, @fontsource-variable/geist, and tailwindcss for improved UI and styling.
- Updated Vite configuration to include path aliasing for easier imports.
- Refactored App.jsx to implement a theme context and improved routing structure.
- Enhanced various components (e.g., ConfirmDialog, DataTable) to utilize new UI components and improve user experience.
- Cleaned up CSS imports and ensured proper styling integration.

Made-with: Cursor
2026-04-26 13:48:59 +07:00

524 lines
22 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, 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 (
<>
<div className="modal-backdrop show" onClick={onClose} />
<div className="modal show d-block" tabIndex="-1">
<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" onClick={onClose} 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>
{/* Страна — 2-буквенный код как в интерфейсах */}
<div className="col-md-6">
<label className="form-label required">Страна</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconMapPin size={16} />
</span>
<input
type="text"
className="form-control ps-8"
value={localServer.country || ''}
onChange={(e) => handleFieldChange('country', e.target.value.toUpperCase().slice(0, 2))}
placeholder="RU, SE, DE…"
maxLength={2}
required
/>
</div>
<div className="form-text">2-буквенный код страны (ISO 3166-1 alpha-2)</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>
)}
{/* Группа объединения — для домашнего роутера с мультиваном: один ключ у нескольких записей = одна карточка в списке */}
{localServer.type === 'home' && (
<div className="col-12">
<label className="form-label">
Группа (объединение в списке)
</label>
<input
type="text"
className="form-control"
value={localServer.groupKey || ''}
onChange={(e) => {
const v = e.target.value.trim();
handleFieldChange('groupKey', v || '');
}}
placeholder="Например: home-multivan — две записи с одним ключом отобразятся как один роутер"
/>
<div className="form-text">
Укажите одинаковый ключ у обоих WAN-записей одного роутера, чтобы в разделе «Серверы» они отображались одной карточкой. ID и привязки (интерфейсы, фильтры) не меняются.
</div>
</div>
)}
</div>
{/* MikroTik RouterOS API — для jumphost и домашних (входных) роутеров */}
{(localServer.type === 'jumphost' || localServer.type === 'home') && (
<div className="mt-4 pt-3 border-top">
<label className="form-label d-flex align-items-center gap-2">
<IconRouter size={18} />
MikroTik RouterOS API
</label>
<div className="row g-3">
<div className="col-md-4">
<label className="form-label small">Хост</label>
<input
type="text"
className="form-control form-control-sm"
value={localServer.mikrotikHost || ''}
onChange={(e) => handleFieldChange('mikrotikHost', e.target.value)}
placeholder={localServer.ip || 'IP или DNS'}
/>
</div>
<div className="col-md-2">
<label className="form-label small">Порт</label>
<input
type="number"
className="form-control form-control-sm"
value={localServer.mikrotikPort || '80'}
onChange={(e) => handleFieldChange('mikrotikPort', e.target.value)}
placeholder="80"
/>
</div>
<div className="col-md-3">
<label className="form-label small">Пользователь</label>
<input
type="text"
className="form-control form-control-sm"
value={localServer.mikrotikUser || 'admin'}
onChange={(e) => handleFieldChange('mikrotikUser', e.target.value)}
placeholder="admin"
/>
</div>
<div className="col-md-3">
<label className="form-label small">Пароль</label>
<input
type="password"
className="form-control form-control-sm"
value={localServer.mikrotikPassword ?? ''}
onChange={(e) => handleFieldChange('mikrotikPassword', e.target.value)}
placeholder={localServer.hasMikrotikPassword ? '••••••' : 'Введите пароль'}
/>
</div>
</div>
<div className="mt-2 d-flex align-items-center gap-2">
<button
type="button"
className="btn btn-outline-primary btn-sm d-flex align-items-center gap-1"
disabled={mikrotikTesting || !(localServer.mikrotikHost || localServer.ip)}
onClick={async () => {
setMikrotikTestResult(null);
setMikrotikTesting(true);
try {
const hasPassword = localServer.mikrotikPassword && String(localServer.mikrotikPassword).trim() !== '';
const payload = hasPassword
? {
host: localServer.mikrotikHost || localServer.ip,
port: localServer.mikrotikPort || 80,
user: localServer.mikrotikUser || 'admin',
password: localServer.mikrotikPassword,
}
: (localServer.hasMikrotikPassword && (localServer.id || localServer.dns || localServer.ip))
? { serverId: localServer.id || localServer.dns || localServer.ip }
: null;
if (!payload) {
setMikrotikTestResult({ ok: false, message: 'Введите пароль или сохраните сервер с паролем' });
return;
}
const { data } = await api.post('/mikrotik/test-connection', payload);
setMikrotikTestResult(data);
} catch (err) {
setMikrotikTestResult({
ok: false,
message: err.response?.data?.message || err.message || 'Ошибка соединения',
});
} finally {
setMikrotikTesting(false);
}
}}
>
<IconPlug size={14} />
{mikrotikTesting ? 'Проверка...' : 'Проверить соединение'}
</button>
{mikrotikTestResult && (
<span className={mikrotikTestResult.ok ? 'text-success' : 'text-danger'}>
{mikrotikTestResult.ok ? '✓ ' + (mikrotikTestResult.message || 'OK') : '✗ ' + mikrotikTestResult.message}
</span>
)}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</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;