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:
@@ -117,6 +117,48 @@ function BillingManager() {
|
|||||||
fetchServers();
|
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 () => {
|
const fetchExchangeRates = async () => {
|
||||||
setRatesLoading(true);
|
setRatesLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -193,7 +235,11 @@ function BillingManager() {
|
|||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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('Данные успешно сохранены');
|
setSuccess('Данные успешно сохранены');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -393,8 +439,9 @@ function BillingManager() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Снимаем флаг _external при редактировании (запись теперь заполнена)
|
||||||
setBillingData(prev => prev.map(item =>
|
setBillingData(prev => prev.map(item =>
|
||||||
item.id === editingItem.id ? editingItem : item
|
item.id === editingItem.id ? { ...editingItem, _external: false } : item
|
||||||
));
|
));
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSuccess('Элемент обновлен');
|
setSuccess('Элемент обновлен');
|
||||||
@@ -494,7 +541,7 @@ function BillingManager() {
|
|||||||
actions={
|
actions={
|
||||||
<PageHeaderActions
|
<PageHeaderActions
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onRefresh={fetchBillingData}
|
onRefresh={() => { fetchBillingData(); fetchServers(); }}
|
||||||
disableRefresh={loading}
|
disableRefresh={loading}
|
||||||
onExport={exportData}
|
onExport={exportData}
|
||||||
disableExport={loading}
|
disableExport={loading}
|
||||||
@@ -734,7 +781,14 @@ function BillingManager() {
|
|||||||
<td>
|
<td>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<div>
|
<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">
|
<div className="text-muted small d-flex align-items-center gap-2">
|
||||||
<span>{item.provider}</span>
|
<span>{item.provider}</span>
|
||||||
{linkedServer && (
|
{linkedServer && (
|
||||||
|
|||||||
+22
-325
@@ -1,30 +1,21 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||||
import FormModal from './components/FormModal.jsx';
|
|
||||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||||
|
import FormModal from './components/FormModal.jsx';
|
||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconX,
|
IconX,
|
||||||
IconDatabase,
|
IconDatabase,
|
||||||
IconRefresh,
|
|
||||||
IconArrowsExchange,
|
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconUpload,
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconServer,
|
IconServer,
|
||||||
IconChevronDown,
|
|
||||||
IconFilter,
|
IconFilter,
|
||||||
IconMapPin,
|
|
||||||
IconWorld,
|
|
||||||
IconList,
|
IconList,
|
||||||
IconLayoutGrid,
|
IconLayoutGrid,
|
||||||
IconNetwork,
|
|
||||||
IconGitBranch,
|
IconGitBranch,
|
||||||
IconSquare,
|
IconSquare,
|
||||||
IconSquareCheck,
|
IconSquareCheck,
|
||||||
@@ -32,13 +23,12 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import GraphView from './GraphView';
|
import GraphView from './GraphView';
|
||||||
import {
|
import {
|
||||||
makeGateway,
|
|
||||||
normalizeGateways,
|
normalizeGateways,
|
||||||
needsGateways as NEEDS_GATEWAYS,
|
needsGateways as NEEDS_GATEWAYS,
|
||||||
SERVER_TYPE_OPTIONS
|
SERVER_TYPE_OPTIONS
|
||||||
} from './utils/serverUtils.js';
|
} from './utils/serverUtils.js';
|
||||||
import {
|
import {
|
||||||
EditServerModal,
|
ServerModal,
|
||||||
LinkGeneratorModal,
|
LinkGeneratorModal,
|
||||||
ServerSidebar,
|
ServerSidebar,
|
||||||
ServerCard,
|
ServerCard,
|
||||||
@@ -99,21 +89,8 @@ function ServerManager() {
|
|||||||
const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false);
|
const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false);
|
||||||
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
|
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
|
||||||
|
|
||||||
// === Новый сервер ===
|
// === Ошибки форм ===
|
||||||
const [newServer, setNewServer] = useState({
|
const [modalError, setModalError] = useState('');
|
||||||
id: '',
|
|
||||||
ip: '',
|
|
||||||
extIp: '',
|
|
||||||
internalIp: '',
|
|
||||||
dns: '',
|
|
||||||
country: '',
|
|
||||||
provider: '',
|
|
||||||
tunnel: 'GRE',
|
|
||||||
type: 'jumphost',
|
|
||||||
gateway: '',
|
|
||||||
gateways: [makeGateway({ primary: true })],
|
|
||||||
});
|
|
||||||
const [customProvider, setCustomProvider] = useState('');
|
|
||||||
|
|
||||||
// === URL Settings ===
|
// === URL Settings ===
|
||||||
const [urlSettings, setUrlSettings] = useState({
|
const [urlSettings, setUrlSettings] = useState({
|
||||||
@@ -286,67 +263,21 @@ function ServerManager() {
|
|||||||
|
|
||||||
// === CRUD Operations ===
|
// === CRUD Operations ===
|
||||||
const handleOpenAddServerModal = () => {
|
const handleOpenAddServerModal = () => {
|
||||||
|
setModalError('');
|
||||||
setAddServerModalOpen(true);
|
setAddServerModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseAddServerModal = () => {
|
const handleCloseAddServerModal = () => {
|
||||||
setAddServerModalOpen(false);
|
setAddServerModalOpen(false);
|
||||||
setNewServer({
|
setModalError('');
|
||||||
id: '',
|
|
||||||
ip: '',
|
|
||||||
extIp: '',
|
|
||||||
internalIp: '',
|
|
||||||
dns: '',
|
|
||||||
country: '',
|
|
||||||
provider: '',
|
|
||||||
tunnel: 'GRE',
|
|
||||||
type: 'jumphost',
|
|
||||||
gateway: '',
|
|
||||||
gateways: [makeGateway({ primary: true })],
|
|
||||||
});
|
|
||||||
setCustomProvider('');
|
|
||||||
setError('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddServerFromModal = () => {
|
const handleAddServerFromModal = (serverData) => {
|
||||||
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 nextId = `srv-${servers.length + 1}`;
|
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 = {
|
const serverToAdd = {
|
||||||
...newServer,
|
...serverData,
|
||||||
provider: finalProvider,
|
id: serverData.id || nextId,
|
||||||
id: newServer.id || nextId,
|
type: normalizeServerType(serverData.type),
|
||||||
type: normalizeServerType(newServer.type),
|
|
||||||
gateways: normalizedGws,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setServers([...servers, serverToAdd]);
|
setServers([...servers, serverToAdd]);
|
||||||
@@ -391,24 +322,16 @@ function ServerManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (server) => {
|
const handleEdit = (server) => {
|
||||||
|
setModalError('');
|
||||||
setEditModalServer({ ...server });
|
setEditModalServer({ ...server });
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditModalChange = (updatedServer) => {
|
|
||||||
setEditModalServer(updatedServer);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditModalSave = (updatedServer) => {
|
const handleEditModalSave = (updatedServer) => {
|
||||||
const normalizedServer = {
|
const normalizedServer = {
|
||||||
...updatedServer,
|
...updatedServer,
|
||||||
type: normalizeServerType(updatedServer.type),
|
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 => {
|
setServers(servers.map(s => {
|
||||||
const sameId = s.id && updatedServer.id ? s.id === updatedServer.id : false;
|
const sameId = s.id && updatedServer.id ? s.id === updatedServer.id : false;
|
||||||
const sameIp = s.ip === updatedServer.ip;
|
const sameIp = s.ip === updatedServer.ip;
|
||||||
@@ -423,6 +346,7 @@ function ServerManager() {
|
|||||||
const handleEditModalClose = () => {
|
const handleEditModalClose = () => {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setEditModalServer(null);
|
setEditModalServer(null);
|
||||||
|
setModalError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = (server) => {
|
const confirmDelete = (server) => {
|
||||||
@@ -1154,12 +1078,14 @@ function ServerManager() {
|
|||||||
icon={IconTrash}
|
icon={IconTrash}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EditServerModal
|
{/* Единое модальное окно для редактирования */}
|
||||||
|
<ServerModal
|
||||||
show={editModalOpen}
|
show={editModalOpen}
|
||||||
|
mode="edit"
|
||||||
server={editModalServer}
|
server={editModalServer}
|
||||||
onChange={handleEditModalChange}
|
|
||||||
onSave={handleEditModalSave}
|
onSave={handleEditModalSave}
|
||||||
onClose={handleEditModalClose}
|
onClose={handleEditModalClose}
|
||||||
|
error={modalError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LinkGeneratorModal
|
<LinkGeneratorModal
|
||||||
@@ -1172,243 +1098,14 @@ function ServerManager() {
|
|||||||
onClose={handleLinkGeneratorClose}
|
onClose={handleLinkGeneratorClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add Server Modal */}
|
{/* Единое модальное окно для добавления */}
|
||||||
<FormModal
|
<ServerModal
|
||||||
show={addServerModalOpen}
|
show={addServerModalOpen}
|
||||||
|
mode="add"
|
||||||
|
onSave={handleAddServerFromModal}
|
||||||
onClose={handleCloseAddServerModal}
|
onClose={handleCloseAddServerModal}
|
||||||
onSubmit={handleAddServerFromModal}
|
error={modalError}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Graph Connection Modal */}
|
{/* Graph Connection Modal */}
|
||||||
<FormModal
|
<FormModal
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ export default function BillingCard({
|
|||||||
<div className="d-flex align-items-center me-3">
|
<div className="d-flex align-items-center me-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="fw-semibold">{item.hostName}</span>
|
<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 mx-2">•</span>
|
||||||
<span className="text-muted">{item.provider}</span>
|
<span className="text-muted">{item.provider}</span>
|
||||||
</div>
|
</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,3 +1,4 @@
|
|||||||
|
export { default as ServerModal } from './ServerModal.jsx';
|
||||||
export { default as EditServerModal } from './EditServerModal.jsx';
|
export { default as EditServerModal } from './EditServerModal.jsx';
|
||||||
export { default as DeleteServerModal } from './DeleteServerModal.jsx';
|
export { default as DeleteServerModal } from './DeleteServerModal.jsx';
|
||||||
export { default as LinkGeneratorModal } from './LinkGeneratorModal.jsx';
|
export { default as LinkGeneratorModal } from './LinkGeneratorModal.jsx';
|
||||||
|
|||||||
Reference in New Issue
Block a user