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();
|
||||
}, []);
|
||||
|
||||
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
|
||||
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
@@ -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,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';
|
||||
|
||||
Reference in New Issue
Block a user