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

This commit is contained in:
2026-01-20 17:31:41 +07:00
parent d173112bae
commit 86d4ecb7e2
5 changed files with 673 additions and 329 deletions
+58 -4
View File
@@ -117,6 +117,48 @@ function BillingManager() {
fetchServers();
}, []);
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
useEffect(() => {
if (servers.length === 0 || loading) return;
setBillingData((prev) => {
const existingServerIds = new Set(
prev.map(b => b.serverId).filter(Boolean)
);
// Находим серверы без записей в биллинге
const serversToAdd = servers.filter(server =>
server.id && !existingServerIds.has(server.id)
);
if (serversToAdd.length === 0) return prev;
// Создаём записи с флагом _external
const newItems = serversToAdd.map(server => ({
id: `billing-${server.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
hostName: server.dns || server.ip,
purpose: '',
country: server.country || '',
provider: server.provider || '',
loginUrl: '',
monthlyCost: 0,
monthlyCostCurrency: 'USD',
nextPaymentDate: '',
lastPaymentDate: '',
lastPaymentAmount: 0,
lastPaymentCurrency: 'USD',
status: 'active',
notes: '',
serverId: server.id,
payments: [],
_external: true, // флаг "не заполнен"
}));
return [...prev, ...newItems];
});
}, [servers, loading]);
const fetchExchangeRates = async () => {
setRatesLoading(true);
try {
@@ -193,7 +235,11 @@ function BillingManager() {
const handleSaveChanges = async () => {
setLoading(true);
try {
await api.post(`/billing`, { domains: billingData });
// Убираем служебное поле _external перед сохранением
const payload = billingData.map(({ _external, ...rest }) => rest);
await api.post(`/billing`, { domains: payload });
// После сохранения снимаем флаг _external со всех записей
setBillingData(prev => prev.map(item => ({ ...item, _external: false })));
setSuccess('Данные успешно сохранены');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
@@ -393,8 +439,9 @@ function BillingManager() {
return;
}
// Снимаем флаг _external при редактировании (запись теперь заполнена)
setBillingData(prev => prev.map(item =>
item.id === editingItem.id ? editingItem : item
item.id === editingItem.id ? { ...editingItem, _external: false } : item
));
setShowEditModal(false);
setSuccess('Элемент обновлен');
@@ -494,7 +541,7 @@ function BillingManager() {
actions={
<PageHeaderActions
loading={loading}
onRefresh={fetchBillingData}
onRefresh={() => { fetchBillingData(); fetchServers(); }}
disableRefresh={loading}
onExport={exportData}
disableExport={loading}
@@ -734,7 +781,14 @@ function BillingManager() {
<td>
<div className="d-flex align-items-center">
<div>
<div className="fw-semibold">{item.hostName}</div>
<div className="fw-semibold">
{item.hostName}
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения">
не заполнен
</span>
)}
</div>
<div className="text-muted small d-flex align-items-center gap-2">
<span>{item.provider}</span>
{linkedServer && (
+22 -325
View File
@@ -1,30 +1,21 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import api from './lib/api.js';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import FormModal from './components/FormModal.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import FormModal from './components/FormModal.jsx';
import FormField from './components/FormField.jsx';
import {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconRefresh,
IconArrowsExchange,
IconDownload,
IconUpload,
IconAlertTriangle,
IconServer,
IconChevronDown,
IconFilter,
IconMapPin,
IconWorld,
IconList,
IconLayoutGrid,
IconNetwork,
IconGitBranch,
IconSquare,
IconSquareCheck,
@@ -32,13 +23,12 @@ import {
} from '@tabler/icons-react';
import GraphView from './GraphView';
import {
makeGateway,
normalizeGateways,
needsGateways as NEEDS_GATEWAYS,
SERVER_TYPE_OPTIONS
} from './utils/serverUtils.js';
import {
EditServerModal,
ServerModal,
LinkGeneratorModal,
ServerSidebar,
ServerCard,
@@ -99,21 +89,8 @@ function ServerManager() {
const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false);
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
// === Новый сервер ===
const [newServer, setNewServer] = useState({
id: '',
ip: '',
extIp: '',
internalIp: '',
dns: '',
country: '',
provider: '',
tunnel: 'GRE',
type: 'jumphost',
gateway: '',
gateways: [makeGateway({ primary: true })],
});
const [customProvider, setCustomProvider] = useState('');
// === Ошибки форм ===
const [modalError, setModalError] = useState('');
// === URL Settings ===
const [urlSettings, setUrlSettings] = useState({
@@ -286,67 +263,21 @@ function ServerManager() {
// === CRUD Operations ===
const handleOpenAddServerModal = () => {
setModalError('');
setAddServerModalOpen(true);
};
const handleCloseAddServerModal = () => {
setAddServerModalOpen(false);
setNewServer({
id: '',
ip: '',
extIp: '',
internalIp: '',
dns: '',
country: '',
provider: '',
tunnel: 'GRE',
type: 'jumphost',
gateway: '',
gateways: [makeGateway({ primary: true })],
});
setCustomProvider('');
setError('');
setModalError('');
};
const handleAddServerFromModal = () => {
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.type) {
setError('Все обязательные поля должны быть заполнены.');
return;
}
if (NEEDS_GATEWAYS(newServer.type)) {
const normalizedGws = normalizeGateways(newServer.gateways, newServer.gateway || newServer.dns || newServer.ip);
if (!normalizedGws.length) {
setError('Добавьте хотя бы один gateway и отметьте основной.');
return;
}
newServer.gateways = normalizedGws;
} else {
newServer.gateways = [];
}
if (newServer.provider === 'Другой' && !customProvider.trim()) {
setError('Пожалуйста, введите название провайдера.');
return;
}
setError('');
const finalProvider = newServer.provider === 'Другой' ? customProvider : newServer.provider;
const handleAddServerFromModal = (serverData) => {
const nextId = `srv-${servers.length + 1}`;
const normalizedGws = NEEDS_GATEWAYS(newServer.type)
? normalizeGateways(newServer.gateways, newServer.gateway || newServer.dns || newServer.ip)
: [];
if (NEEDS_GATEWAYS(newServer.type) && (!normalizedGws || normalizedGws.length === 0)) {
setError('Для jumphost/exit добавьте хотя бы один gateway и отметьте основной.');
return;
}
const serverToAdd = {
...newServer,
provider: finalProvider,
id: newServer.id || nextId,
type: normalizeServerType(newServer.type),
gateways: normalizedGws,
...serverData,
id: serverData.id || nextId,
type: normalizeServerType(serverData.type),
};
setServers([...servers, serverToAdd]);
@@ -391,24 +322,16 @@ function ServerManager() {
};
const handleEdit = (server) => {
setModalError('');
setEditModalServer({ ...server });
setEditModalOpen(true);
};
const handleEditModalChange = (updatedServer) => {
setEditModalServer(updatedServer);
};
const handleEditModalSave = (updatedServer) => {
const normalizedServer = {
...updatedServer,
type: normalizeServerType(updatedServer.type),
gateways: NEEDS_GATEWAYS(updatedServer.type) ? normalizeGateways(updatedServer.gateways, updatedServer.gateway || updatedServer.dns || updatedServer.ip) : [],
};
if (NEEDS_GATEWAYS(normalizedServer.type) && normalizedServer.gateways.length === 0) {
setError('Для jumphost/exit необходим хотя бы один gateway с признаком основного.');
return;
}
setServers(servers.map(s => {
const sameId = s.id && updatedServer.id ? s.id === updatedServer.id : false;
const sameIp = s.ip === updatedServer.ip;
@@ -423,6 +346,7 @@ function ServerManager() {
const handleEditModalClose = () => {
setEditModalOpen(false);
setEditModalServer(null);
setModalError('');
};
const confirmDelete = (server) => {
@@ -1154,12 +1078,14 @@ function ServerManager() {
icon={IconTrash}
/>
<EditServerModal
{/* Единое модальное окно для редактирования */}
<ServerModal
show={editModalOpen}
mode="edit"
server={editModalServer}
onChange={handleEditModalChange}
onSave={handleEditModalSave}
onClose={handleEditModalClose}
error={modalError}
/>
<LinkGeneratorModal
@@ -1172,243 +1098,14 @@ function ServerManager() {
onClose={handleLinkGeneratorClose}
/>
{/* Add Server Modal */}
<FormModal
{/* Единое модальное окно для добавления */}
<ServerModal
show={addServerModalOpen}
mode="add"
onSave={handleAddServerFromModal}
onClose={handleCloseAddServerModal}
onSubmit={handleAddServerFromModal}
title="Добавить новый сервер"
submitLabel="Добавить сервер"
submitIcon={IconPlus}
size="md"
>
{error && (
<div className="alert alert-danger mb-3" role="alert">
{error}
</div>
)}
<div className="row g-3">
<div className="col-md-6">
<FormField
label="IP адрес"
name="ip"
value={newServer.ip}
onChange={(val) => setNewServer({ ...newServer, ip: val })}
placeholder="192.168.1.1"
required
icon={IconServer}
/>
</div>
<div className="col-md-6">
<FormField
label="DNS имя"
name="dns"
value={newServer.dns}
onChange={(val) => setNewServer({ ...newServer, dns: val })}
placeholder="srv1.example.com"
required
icon={IconWorld}
/>
</div>
<div className="col-md-6">
<FormField
label="Внешний IP"
name="extIp"
value={newServer.extIp}
onChange={(val) => setNewServer({ ...newServer, extIp: val })}
placeholder="Опционально"
icon={IconServer}
/>
</div>
<div className="col-md-6">
<FormField
label="Внутренний IP"
name="internalIp"
value={newServer.internalIp}
onChange={(val) => setNewServer({ ...newServer, internalIp: val })}
placeholder="10.x.x.x"
icon={IconServer}
/>
</div>
<div className="col-md-6">
<FormField
label="Страна"
name="country"
type="select"
value={newServer.country}
onChange={(val) => setNewServer({ ...newServer, country: val })}
required
icon={IconMapPin}
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)' },
]}
/>
</div>
<div className="col-md-6">
<FormField
label="Провайдер"
name="provider"
type="select"
value={newServer.provider}
onChange={(val) => setNewServer({ ...newServer, provider: val })}
required
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: 'Другой', label: 'Другой...' }
]}
/>
</div>
{newServer.provider === 'Другой' && (
<div className="col-12">
<FormField
label="Название провайдера"
name="customProvider"
value={customProvider}
onChange={setCustomProvider}
placeholder="Введите название"
required
/>
</div>
)}
<div className="col-md-6">
<FormField
label="Тип сервера"
name="type"
type="select"
value={newServer.type}
onChange={(val) => setNewServer({ ...newServer, type: val })}
required
options={[
{ value: '', label: 'Выберите тип', disabled: true },
...SERVER_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }))
]}
/>
</div>
<div className="col-md-6">
<FormField
label="Тип туннеля"
name="tunnel"
type="select"
value={newServer.tunnel}
onChange={(val) => setNewServer({ ...newServer, tunnel: val })}
required
options={[
{ value: 'GRE', label: 'GRE' },
{ value: 'IPSec', label: 'IPSec' },
{ value: 'WireGuard', label: 'WireGuard' },
{ value: 'OpenVPN', label: 'OpenVPN' }
]}
/>
</div>
<div className="col-12">
<FormField
label="Основной шлюз"
name="gateway"
value={newServer.gateway}
onChange={(val) => setNewServer({ ...newServer, gateway: val })}
placeholder="Cloudflare, Hetzner и т.д."
required
/>
</div>
</div>
{NEEDS_GATEWAYS(newServer.type) && (
<div className="mt-3">
<div className="form-label">Шлюзы</div>
<div className="list-group list-group-flush border rounded mb-2">
{(newServer.gateways || []).map((gw, idx) => (
<div key={gw.id || idx} className="list-group-item">
<div className="row g-2 align-items-center">
<div className="col-md-4">
<input
type="text"
className="form-control form-control-sm"
value={gw.name}
onChange={(e) => {
const list = newServer.gateways.map((g, i) => i === idx ? { ...g, name: e.target.value } : g);
setNewServer({ ...newServer, gateways: list });
}}
placeholder="Имя шлюза"
/>
</div>
<div className="col-md-4">
<input
type="text"
className="form-control form-control-sm"
value={gw.ip || ''}
onChange={(e) => {
const list = newServer.gateways.map((g, i) => i === idx ? { ...g, ip: e.target.value } : g);
setNewServer({ ...newServer, gateways: list });
}}
placeholder="IP"
/>
</div>
<div className="col-md-3">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
checked={gw.primary}
onChange={(e) => {
const list = newServer.gateways.map((g, i) => ({ ...g, primary: i === idx ? e.target.checked : false }));
if (!list.some(g => g.primary)) list[idx].primary = true;
setNewServer({ ...newServer, gateways: list });
}}
/>
<label className="form-check-label small">Основной</label>
</div>
</div>
<div className="col-md-1">
{newServer.gateways.length > 1 && (
<button
type="button"
className="btn btn-ghost-danger btn-icon btn-sm"
onClick={() => {
const list = newServer.gateways.filter((_, i) => i !== idx);
if (!list.some(g => g.primary) && list.length > 0) list[0].primary = true;
setNewServer({ ...newServer, gateways: list });
}}
>
<IconX size={14} />
</button>
)}
</div>
</div>
</div>
))}
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => {
setNewServer((prev) => {
const list = Array.isArray(prev.gateways) ? prev.gateways : [];
return { ...prev, gateways: [...list, makeGateway({ primary: list.length === 0 })] };
});
}}
>
<IconPlus size={14} className="me-1" /> Добавить шлюз
</button>
</div>
)}
</FormModal>
error={modalError}
/>
{/* Graph Connection Modal */}
<FormModal
@@ -111,6 +111,11 @@ export default function BillingCard({
<div className="d-flex align-items-center me-3">
<div>
<span className="fw-semibold">{item.hostName}</span>
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения">
не заполнен
</span>
)}
<span className="text-muted mx-2"></span>
<span className="text-muted">{item.provider}</span>
</div>
@@ -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;
+1
View File
@@ -1,3 +1,4 @@
export { default as ServerModal } from './ServerModal.jsx';
export { default as EditServerModal } from './EditServerModal.jsx';
export { default as DeleteServerModal } from './DeleteServerModal.jsx';
export { default as LinkGeneratorModal } from './LinkGeneratorModal.jsx';