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
+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