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:
+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
|
||||
|
||||
Reference in New Issue
Block a user