Publish Docker image / build-and-push (push) Successful in 2m24s
- 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
524 lines
22 KiB
React
524 lines
22 KiB
React
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;
|