feat(BillingManager, ServerManager): implement automatic synchronization of billing entries for new servers; enhance server management modals for improved error handling and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin } from '@tabler/icons-react';
|
||||
import {
|
||||
makeGateway,
|
||||
normalizeGateways,
|
||||
needsGateways as NEEDS_GATEWAYS,
|
||||
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="col-md-6">
|
||||
<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>
|
||||
|
||||
{/* Тип туннеля */}
|
||||
<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;
|
||||
Reference in New Issue
Block a user