diff --git a/frontend/src/BillingManager.jsx b/frontend/src/BillingManager.jsx index fc44d9b..5fdb41f 100644 --- a/frontend/src/BillingManager.jsx +++ b/frontend/src/BillingManager.jsx @@ -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={ { fetchBillingData(); fetchServers(); }} disableRefresh={loading} onExport={exportData} disableExport={loading} @@ -734,7 +781,14 @@ function BillingManager() {
-
{item.hostName}
+
+ {item.hostName} + {item._external && ( + + не заполнен + + )} +
{item.provider} {linkedServer && ( diff --git a/frontend/src/ServerManager.jsx b/frontend/src/ServerManager.jsx index 560cfd2..cfe2208 100644 --- a/frontend/src/ServerManager.jsx +++ b/frontend/src/ServerManager.jsx @@ -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} /> - - {/* Add Server Modal */} - - {error && ( -
- {error} -
- )} - -
-
- setNewServer({ ...newServer, ip: val })} - placeholder="192.168.1.1" - required - icon={IconServer} - /> -
-
- setNewServer({ ...newServer, dns: val })} - placeholder="srv1.example.com" - required - icon={IconWorld} - /> -
-
- setNewServer({ ...newServer, extIp: val })} - placeholder="Опционально" - icon={IconServer} - /> -
-
- setNewServer({ ...newServer, internalIp: val })} - placeholder="10.x.x.x" - icon={IconServer} - /> -
-
- 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)' }, - ]} - /> -
-
- 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: 'Другой...' } - ]} - /> -
- - {newServer.provider === 'Другой' && ( -
- -
- )} - -
- setNewServer({ ...newServer, type: val })} - required - options={[ - { value: '', label: 'Выберите тип', disabled: true }, - ...SERVER_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label })) - ]} - /> -
-
- setNewServer({ ...newServer, tunnel: val })} - required - options={[ - { value: 'GRE', label: 'GRE' }, - { value: 'IPSec', label: 'IPSec' }, - { value: 'WireGuard', label: 'WireGuard' }, - { value: 'OpenVPN', label: 'OpenVPN' } - ]} - /> -
-
- setNewServer({ ...newServer, gateway: val })} - placeholder="Cloudflare, Hetzner и т.д." - required - /> -
-
- - {NEEDS_GATEWAYS(newServer.type) && ( -
-
Шлюзы
-
- {(newServer.gateways || []).map((gw, idx) => ( -
-
-
- { - const list = newServer.gateways.map((g, i) => i === idx ? { ...g, name: e.target.value } : g); - setNewServer({ ...newServer, gateways: list }); - }} - placeholder="Имя шлюза" - /> -
-
- { - const list = newServer.gateways.map((g, i) => i === idx ? { ...g, ip: e.target.value } : g); - setNewServer({ ...newServer, gateways: list }); - }} - placeholder="IP" - /> -
-
-
- { - 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 }); - }} - /> - -
-
-
- {newServer.gateways.length > 1 && ( - - )} -
-
-
- ))} -
- -
- )} -
+ error={modalError} + /> {/* Graph Connection Modal */}
{item.hostName} + {item._external && ( + + не заполнен + + )} {item.provider}
diff --git a/frontend/src/components/server/ServerModal.jsx b/frontend/src/components/server/ServerModal.jsx new file mode 100644 index 0000000..d5dab57 --- /dev/null +++ b/frontend/src/components/server/ServerModal.jsx @@ -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 ( +
+
+
+
+
+ {isEditMode ? ( + <> + + Редактировать сервер + + ) : ( + <> + + Добавить новый сервер + + )} +
+ +
+ +
+
+ {error && ( +
+ {error} +
+ )} + +
+ {/* ID - опционально */} +
+ + handleFieldChange('id', e.target.value)} + placeholder="srv-1" + /> +
+ + {/* IP адрес */} +
+ +
+ + + + handleFieldChange('ip', e.target.value)} + placeholder="192.168.1.1" + required + /> +
+
+ + {/* DNS */} +
+ +
+ + + + handleFieldChange('dns', e.target.value)} + placeholder="srv1.example.com" + required + /> +
+
+ + {/* Внешний IP */} +
+ + handleFieldChange('extIp', e.target.value)} + placeholder="Публичный IP, если отличается" + /> +
+ + {/* Внутренний IP */} +
+ + handleFieldChange('internalIp', e.target.value)} + placeholder="10.x.x.x / 192.168.x.x" + /> +
+ + {/* Страна */} +
+ +
+ + + + +
+
+ + {/* Провайдер */} +
+ + +
+ + {/* Кастомный провайдер */} + {localServer.provider === 'Другой' && ( +
+ + setCustomProvider(e.target.value)} + placeholder="Введите название провайдера" + required + /> +
+ )} + + {/* Тип сервера */} +
+ + +
+ + {/* Тип туннеля */} +
+ + +
+ + {/* Основной шлюз (текстовое поле для совместимости) */} +
+ + handleFieldChange('gateway', e.target.value)} + placeholder="Cloudflare, Hetzner и т.д." + /> +
+ Используйте секцию "Шлюзы" ниже для более детальной настройки +
+
+
+ + {/* Секция шлюзов */} + {showGateways && ( +
+
+ + +
+ +
+ {currentGateways.map((gw, idx) => ( +
+
+
+ + handleGatewayChange(idx, 'name', e.target.value)} + placeholder="GW-MSK" + required + /> +
+
+ + handleGatewayChange(idx, 'ip', e.target.value)} + placeholder="10.0.0.1" + /> +
+
+ + handleGatewayChange(idx, 'comment', e.target.value)} + placeholder="Описание" + /> +
+
+
+ handlePrimaryChange(idx, e.target.checked)} + id={`primary-${gw.id || idx}`} + /> + +
+
+
+ {currentGateways.length > 1 && ( + + )} +
+
+
+ {gw.primary ? 'Основной шлюз' : 'Резервный шлюз'} +
+
+ ))} +
+ +
+ Для типов jumphost/exit должен быть минимум один шлюз с признаком "Основной" +
+
+ )} +
+ +
+ + +
+
+
+
+
+ ); +} + +export default ServerModal; diff --git a/frontend/src/components/server/index.js b/frontend/src/components/server/index.js index c834de9..b5136a3 100644 --- a/frontend/src/components/server/index.js +++ b/frontend/src/components/server/index.js @@ -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';