import { useState, useEffect, useRef } 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 FormField from './components/FormField.jsx'; import { IconPlus, IconSearch, IconEdit, IconTrash, IconCheck, IconX, IconDatabase, IconRefresh, IconArrowsExchange, IconDownload, IconUpload, IconAlertTriangle, IconServer, IconChevronDown, IconLink, IconFilter, IconMapPin, IconWorld } from '@tabler/icons-react'; import GraphView from './GraphView'; const API_URL = '/api'; const SERVER_TYPE_OPTIONS = [ { value: 'jumphost', label: 'Jumphost' }, { value: 'exit', label: 'Выходная нода' }, ]; const NEEDS_GATEWAYS = (type) => ['jumphost', 'exit'].includes(String(type || '').toLowerCase()); const makeGateway = (over = {}) => ({ id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, name: '', ip: '', comment: '', primary: false, ...over, }); const normalizeGateways = (gateways = [], fallbackName = '') => { if (!Array.isArray(gateways)) return []; const cleaned = gateways .map((g, idx) => ({ id: g?.id || `${Date.now()}-${idx}`, name: String(g?.name || '').trim(), ip: String(g?.ip || '').trim(), comment: String(g?.comment || '').trim(), primary: Boolean(g?.primary), })) .filter(g => g.name || g.ip || g.comment); if (cleaned.length === 0) { return [{ id: `${Date.now()}-primary`, name: fallbackName || 'gateway', ip: '', comment: '', primary: true }]; } // ensure single primary let hasPrimary = false; cleaned.forEach((g, i) => { if (g.primary && !hasPrimary) { hasPrimary = true; } else { g.primary = false; } }); if (!hasPrimary) cleaned[0].primary = true; return cleaned; }; function ServerManager() { const [servers, setServers] = useState([]); const [newServer, setNewServer] = useState({ id: '', ip: '', extIp: '', internalIp: '', dns: '', country: '', provider: '', tunnel: 'GRE', type: 'jumphost', gateway: '', gateways: [makeGateway({ primary: true })], }); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [editingServer, setEditingServer] = useState(null); const [editingValues, setEditingValues] = useState({}); const [bulkFrom, setBulkFrom] = useState('GRE'); const [bulkTo, setBulkTo] = useState('IPSec'); const [loading, setLoading] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [serverToDelete, setServerToDelete] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [sortField, setSortField] = useState('ip'); const [sortOrder, setSortOrder] = useState('asc'); const [filterTunnel, setFilterTunnel] = useState(''); const [filterCountry, setFilterCountry] = useState(''); const [filterProvider, setFilterProvider] = useState(''); const [filterType, setFilterType] = useState(''); const editInputRef = useRef(null); const pageSize = 10; const [editModalOpen, setEditModalOpen] = useState(false); const [editModalServer, setEditModalServer] = useState(null); // Состояние для генератора ссылок const [linkGeneratorOpen, setLinkGeneratorOpen] = useState(false); const [linkGeneratorServer, setLinkGeneratorServer] = useState(null); const [urlSettings, setUrlSettings] = useState({ baseUrl: 'https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo', cloudflare_gateway: 'SWE-IHOR', bunny_gateway: 'SWE-IHOR', fastly_gateway: 'SWE-IHOR', telegram_gateway: '94.142.140.1', hetzner_gateway: '94.142.140.1', type: 'routes', version: 'v4.rsc' }); const presets = { default: { baseUrl: 'https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo', cloudflare_gateway: 'SWE-IHOR', bunny_gateway: 'SWE-IHOR', fastly_gateway: 'SWE-IHOR', telegram_gateway: '94.142.140.1', hetzner_gateway: '94.142.140.1', type: 'routes', version: 'v4.rsc' }, cloudflareSWE: { baseUrl: 'https://cf.example/api', cloudflare_gateway: 'SWE-IHOR', bunny_gateway: '', fastly_gateway: '', telegram_gateway: '', hetzner_gateway: '', type: 'routes', version: 'v4.rsc' }, hetznerDE: { baseUrl: 'https://hetzner.example/api', cloudflare_gateway: '', bunny_gateway: '', fastly_gateway: '', telegram_gateway: '', hetzner_gateway: '94.142.140.1', type: 'routes', version: 'v4.rsc' } }; // Состояние для мониторинга сети const [networkStatus, setNetworkStatus] = useState({}); const [monitoringEnabled, setMonitoringEnabled] = useState(false); // Состояние для модального окна добавления сервера const [addServerModalOpen, setAddServerModalOpen] = useState(false); const [customProvider, setCustomProvider] = useState(''); const [connections, setConnections] = useState([]); const [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' }); const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false); // Фильтры графа const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true }); const [graphCountryFilter, setGraphCountryFilter] = useState(''); const [graphProviderFilter, setGraphProviderFilter] = useState(''); const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null }); const normalizeServerType = (type) => { const value = String(type || '').toLowerCase(); return SERVER_TYPE_OPTIONS.some((opt) => opt.value === value) ? value : 'jumphost'; }; useEffect(() => { fetchServers(); fetchConnections(); }, []); // Загружаем настройки URL из localStorage useEffect(() => { const savedSettings = localStorage.getItem('urlSettings'); if (savedSettings) { try { const parsed = JSON.parse(savedSettings); setUrlSettings(prev => ({ ...prev, ...parsed })); } catch (e) { console.error('Ошибка при загрузке настроек URL:', e); } } }, []); const fetchServers = async () => { setLoading(true); try { const response = await api.get(`/servers`); const normalized = (Array.isArray(response.data) ? response.data : []).map((srv) => ({ ...srv, type: normalizeServerType(srv.type), gateways: NEEDS_GATEWAYS(srv.type) ? normalizeGateways(srv.gateways, srv.gateway || srv.dns || srv.ip) : (Array.isArray(srv.gateways) ? srv.gateways : []), })); setServers(normalized); setError(''); } catch (error) { console.error('Error fetching servers:', error); setError('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.'); } finally { setLoading(false); } }; const fetchConnections = async () => { try { const response = await api.get(`/server-connections`); if (Array.isArray(response.data)) { setConnections(response.data); } else { setConnections([]); } } catch (error) { console.error('Error fetching server connections:', error); // Не перекрываем возможную ошибку по серверам, просто логируем } }; const handleAddServer = () => { if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.type) { setError('Все обязательные поля должны быть заполнены.'); return; } setError(''); const nextId = `srv-${servers.length + 1}`; const serverToAdd = { ...newServer, id: newServer.id || nextId, type: normalizeServerType(newServer.type), }; setServers([...servers, serverToAdd]); setNewServer({ id: '', ip: '', extIp: '', internalIp: '', dns: '', country: '', provider: '', tunnel: 'GRE', type: 'jumphost', gateway: '', gateways: [makeGateway({ primary: true })], }); }; // Функции для модального окна добавления сервера const handleOpenAddServerModal = () => { 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(''); }; 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 = []; } // Проверяем, если выбран "Другой" провайдер, то customProvider должен быть заполнен if (newServer.provider === 'Другой' && !customProvider.trim()) { setError('Пожалуйста, введите название провайдера.'); return; } setError(''); // Используем customProvider если выбран "Другой" const finalProvider = newServer.provider === 'Другой' ? customProvider : newServer.provider; 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, }; setServers([...servers, serverToAdd]); setNewServer({ id: '', ip: '', extIp: '', internalIp: '', dns: '', country: '', provider: '', tunnel: 'GRE', type: 'jumphost', gateway: '', gateways: [makeGateway({ primary: true })], }); setCustomProvider(''); setAddServerModalOpen(false); setSuccess('Сервер успешно добавлен!'); setTimeout(() => setSuccess(''), 3000); }; const handleEdit = (server) => { 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; return (sameId || sameIp) ? { ...s, ...normalizedServer } : s; })); setEditModalOpen(false); setEditModalServer(null); }; const handleEditModalClose = () => { setEditModalOpen(false); setEditModalServer(null); }; // Функции для генератора ссылок const handleGenerateLink = (server) => { setLinkGeneratorServer(server); setLinkGeneratorOpen(true); }; const handleLinkGeneratorClose = () => { setLinkGeneratorOpen(false); setLinkGeneratorServer(null); }; const generateServerUrl = (server) => { const params = new URLSearchParams(); // Добавляем все параметры из настроек Object.entries(urlSettings).forEach(([key, value]) => { if (key !== 'baseUrl' && value) { params.append(key, value); } }); // Добавляем IP сервера как параметр server params.append('server', server.ip); return `${urlSettings.baseUrl}?${params.toString()}`; }; const applyPreset = (name) => { const p = presets[name]; if (!p) return; setUrlSettings(prev => ({ ...prev, ...p })); localStorage.setItem('urlSettings', JSON.stringify({ ...urlSettings, ...p })); setSuccess('Профиль применён'); setTimeout(() => setSuccess(''), 2000); }; // Функция резервного копирования для http const fallbackCopyToClipboard = (text) => { const textArea = document.createElement('textarea'); textArea.value = text; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand('copy'); setSuccess('Ссылка скопирована в буфер обмена!'); setTimeout(() => setSuccess(''), 3000); } catch (err) { setError('Не удалось скопировать ссылку'); } document.body.removeChild(textArea); }; const copyToClipboard = async (text) => { if (navigator.clipboard) { try { await navigator.clipboard.writeText(text); setSuccess('Ссылка скопирована в буфер обмена!'); setTimeout(() => setSuccess(''), 3000); return; } catch (err) { // fallback ниже } } fallbackCopyToClipboard(text); }; const quickCopyLink = async (server) => { const url = generateServerUrl(server); await copyToClipboard(url); }; const exportAllLinks = () => { const filtered = servers .filter(s => (!filterTunnel || s.tunnel === filterTunnel)) .filter(s => (!filterCountry || s.country === filterCountry)) .filter(s => (!filterProvider || s.provider === filterProvider)) .filter(s => (!filterType || s.type === filterType)); const csvContent = [ ['ID', 'IP', 'Ext IP', 'Internal IP', 'DNS', 'Country', 'Provider', 'Type', 'Tunnel', 'Generated URL'], ...filtered.map(server => [ server.id || '', server.ip, server.extIp || '', server.internalIp || '', server.dns, server.country, server.provider, server.type, server.tunnel, generateServerUrl(server) ]) ].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); link.setAttribute('href', url); link.setAttribute('download', `server_links_${new Date().toISOString().split('T')[0]}.csv`); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const handleCancelEdit = () => { setEditingServer(null); }; const handleDeleteServer = (serverIp) => { setServers(servers.filter(s => s.ip !== serverIp)); }; const confirmDelete = (server) => { setServerToDelete(server); setShowDeleteModal(true); }; const executeDelete = () => { if (serverToDelete) { handleDeleteServer(serverToDelete.ip); setShowDeleteModal(false); setServerToDelete(null); } }; const handleSaveChanges = async () => { setLoading(true); try { const normalizedServers = servers.map((s) => ({ ...s, type: normalizeServerType(s.type) })); setServers(normalizedServers); await api.post(`/servers`, { domains: normalizedServers }); await api.post(`/server-connections`, { domains: connections }); setSuccess('Изменения по серверам и связям успешно сохранены!'); setTimeout(() => setSuccess(''), 3000); } catch (error) { console.error('Error saving changes:', error); setError('Не удалось сохранить изменения по серверам или связям.'); } finally { setLoading(false); } }; const handleBulkUpdate = () => { if (!bulkFrom || !bulkTo) { setError('Оба поля для массового обновления должны быть заполнены.'); return; } setConfirmState({ open: true, text: `Вы уверены, что хотите заменить все туннели "${bulkFrom}" на "${bulkTo}"? Это действие необратимо.`, onConfirm: () => { const updatedServers = servers.map(s => s.tunnel === bulkFrom ? { ...s, tunnel: bulkTo } : s); setServers(updatedServers); setSuccess(`Туннели "${bulkFrom}" были успешно заменены на "${bulkTo}". Не забудьте сохранить изменения.`); setConfirmState({ open: false, text: '', onConfirm: null }); }, }); }; // Сортировка const sortedServers = [...servers].sort((a, b) => { let valA = a[sortField] || ''; let valB = b[sortField] || ''; if (typeof valA === 'string') valA = valA.toLowerCase(); if (typeof valB === 'string') valB = valB.toLowerCase(); if (valA < valB) return sortOrder === 'asc' ? -1 : 1; if (valA > valB) return sortOrder === 'asc' ? 1 : -1; return 0; }); // Фильтрация let filteredServers = sortedServers; if (filterTunnel) { filteredServers = filteredServers.filter(s => s.tunnel === filterTunnel); } if (filterCountry) { filteredServers = filteredServers.filter(s => s.country === filterCountry); } if (filterProvider) { filteredServers = filteredServers.filter(s => s.provider === filterProvider); } if (filterType) { filteredServers = filteredServers.filter(s => s.type === filterType); } // Поиск filteredServers = filteredServers.filter(s => s.ip.toLowerCase().includes(searchTerm.toLowerCase()) || s.dns.toLowerCase().includes(searchTerm.toLowerCase()) || s.country.toLowerCase().includes(searchTerm.toLowerCase()) || s.provider.toLowerCase().includes(searchTerm.toLowerCase()) || (s.type || '').toLowerCase().includes(searchTerm.toLowerCase()) ); // Пагинация const totalPages = Math.ceil(filteredServers.length / pageSize); const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize); // Для фильтров - списки всех уникальных значений const allTunnels = Array.from(new Set(servers.map(s => s.tunnel))); const allCountries = Array.from(new Set(servers.map(s => s.country))); const allProviders = Array.from(new Set(servers.map(s => s.provider))); const allTypes = Array.from(new Set(servers.map(s => s.type).filter(Boolean))); // Сортировка по клику const handleSort = (field) => { if (sortField === field) { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortOrder('asc'); } }; // Улучшенный инлайн-редакт useEffect(() => { if (editingServer && editInputRef.current) { editInputRef.current.focus(); } }, [editingServer]); const handleEditKeyDown = (e, serverIp) => { if (e.key === 'Enter') handleSaveEdit(serverIp); if (e.key === 'Escape') handleCancelEdit(); }; // Функция для преобразования кода страны в emoji-флаг function countryToFlag(isoCode) { if (!isoCode) return ''; return isoCode .toUpperCase() .replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()) ); } // Валидация и добавление связи (общая функция для формы и модального окна графа) const addConnectionInternal = (draft) => { if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) { setError('Все поля для связи должны быть заполнены.'); return false; } if (draft.from === draft.to) { setError('Сервер A и сервер B не могут быть одинаковыми.'); return false; } const next = { ...draft }; setConnections((prev) => [...prev, next]); setSuccess('Связь успешно добавлена!'); setTimeout(() => setSuccess(''), 3000); return true; }; // Добавление новой связи из нижней формы const handleAddConnection = () => { const ok = addConnectionInternal(newConnection); if (!ok) return; setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' }); setError(''); }; // Подтверждение связи, созданной через граф (модальное окно) const handleConfirmGraphConnection = () => { const ok = addConnectionInternal(newConnection); if (!ok) return; setGraphConnectionModalOpen(false); setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' }); setError(''); }; // Удаление связи const handleDeleteConnection = (index) => { setConnections(connections.filter((_, i) => i !== index)); setSuccess('Связь удалена!'); setTimeout(() => setSuccess(''), 3000); }; return ( <> {error && (
{error}
)} {success && (
{success}
)} {/* Page header */}

Серверы

Главная / Управление / Серверы
{Object.keys(presets).map(key => ( ))}
{/* Таблица серверов на всю ширину */}

Список серверов {filteredServers.length}

{/* Фильтры и поиск */}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} />
{paginatedServers.length === 0 ? ( ) : paginatedServers.map((server) => ( ))}
handleSort('ip')}> IP адрес {sortField === 'ip' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} handleSort('type')}> Тип {sortField === 'type' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} Внешний IP Внутренний IP handleSort('dns')}> DNS имя {sortField === 'dns' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} handleSort('country')}> Страна {sortField === 'country' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} handleSort('provider')}> Провайдер {sortField === 'provider' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} handleSort('tunnel')}> Туннель {sortField === 'tunnel' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} handleSort('gateway')}> Основной шлюз {sortField === 'gateway' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} Статус
Ничего не найдено. Измените фильтры или поиск.
{server.ip} {server.type === 'exit' ? 'Выходная нода' : 'Jumphost'} {server.extIp || } {server.internalIp || } {server.dns} {countryToFlag(server.country)} {server.country} {server.provider} {server.tunnel} {server.gateway} Онлайн
{/* Пагинация */} {totalPages > 1 && (
Показано {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length}
)}
{/* Delete Modal - New unified component */} setShowDeleteModal(false)} onConfirm={executeDelete} title="Подтверждение удаления" message={ serverToDelete ? ( <>

Вы уверены, что хотите удалить сервер {serverToDelete.ip}?

Это действие нельзя отменить.

) : null } confirmLabel="Удалить" variant="danger" icon={IconTrash} /> {/* Edit Server Modal */} {/* Link Generator Modal */} {/* Add Server Modal - New unified component */} {error && (
{error}
)} setNewServer({ ...newServer, ip: val })} placeholder="192.168.1.1" required icon={IconServer} helpText="Введите IPv4 адрес сервера" /> setNewServer({ ...newServer, extIp: val })} placeholder="Публичный IP, если отличается" icon={IconServer} /> setNewServer({ ...newServer, internalIp: val })} placeholder="10.x.x.x / 192.168.x.x" icon={IconServer} /> setNewServer({ ...newServer, dns: val })} placeholder="srv1.shx.su" required icon={IconWorld} helpText="Полное доменное имя сервера" /> setNewServer({ ...newServer, country: val })} required icon={IconMapPin} options={[ { value: '', label: 'Выберите страну', disabled: true }, { value: 'RU', label: 'Россия (RU)' }, { value: 'SWE', label: 'Швеция (SWE)' }, { value: 'US', label: 'США (US)' }, { value: 'DE', label: 'Германия (DE)' }, { value: 'NL', label: 'Нидерланды (NL)' }, { value: 'SG', label: 'Сингапур (SG)' } ]} /> setNewServer({ ...newServer, provider: val })} required options={[ { value: '', label: 'Выберите провайдера', disabled: true }, { value: 'Yandex', label: 'Yandex Cloud' }, { value: 'AWS', label: 'Amazon Web Services' }, { value: 'Google', label: 'Google Cloud' }, { value: 'Azure', label: 'Microsoft Azure' }, { value: 'Hetzner', label: 'Hetzner' }, { value: 'OVH', label: 'OVH' }, { value: 'DigitalOcean', label: 'DigitalOcean' }, { value: 'Vultr', label: 'Vultr' }, { value: 'Linode', label: 'Linode' }, { value: 'VPSVILLE', label: 'VPSVILLE' }, { value: 'HIPHOST', label: 'HIPHOST' }, { 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 })) ]} helpText="Выберите роль: jumphost или выходная нода" /> setNewServer({ ...newServer, tunnel: val })} required options={[ { value: 'GRE', label: 'GRE (Generic Routing Encapsulation)' }, { value: 'IPSec', label: 'IPSec' }, { value: 'WireGuard', label: 'WireGuard' }, { value: 'OpenVPN', label: 'OpenVPN' } ]} /> setNewServer({ ...newServer, gateway: val })} placeholder="Cloudflare, Hetzner, Мой шлюз и т.д." required helpText="Имя основного шлюза для маршрутизации" /> {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="GW-MSK" required />
{ const list = newServer.gateways.map((g, i) => i === idx ? { ...g, ip: e.target.value } : g); setNewServer({ ...newServer, gateways: list }); }} placeholder="10.0.0.1" />
{ const list = newServer.gateways.map((g, i) => i === idx ? { ...g, comment: e.target.value } : g); setNewServer({ ...newServer, gateways: list }); }} placeholder="Основной шлюз" />
{ 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 }); }} title="Основной шлюз" />
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
{newServer.gateways.length > 1 && ( )}
))}
Для jumphost/exit должен быть один основной gateway.
)}

Граф связей между серверами

Поддержка: GRE, IPSec, WireGuard, OpenVPN
Фильтры графа:
{['GRE','IPSec','WireGuard','OpenVPN'].map(t => ( ))}
(!graphCountryFilter || s.country === graphCountryFilter) && (!graphProviderFilter || s.provider === graphProviderFilter))} connections={connections.filter(c => graphTunnelFilters[c.tunnelType])} onCreateConnection={({ from, to }) => { // Открываем современный флоу создания связи прямо поверх графа setNewConnection(prev => ({ ...prev, from, to, // Если серверы уже имеют тип туннеля, предлагаем его по умолчанию tunnelType: prev.tunnelType || servers.find(s => s.ip === from)?.tunnel || servers.find(s => s.ip === to)?.tunnel || 'GRE', })); setGraphConnectionModalOpen(true); }} />
GRE
IPSec
WireGuard
OpenVPN
{/* Модальное окно для быстрого создания связи после соединения узлов на графе */} { setGraphConnectionModalOpen(false); setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' }); setError(''); }} onSubmit={handleConfirmGraphConnection} title="Создать связь между серверами" submitLabel="Создать связь" submitIcon={IconPlus} size="md" > {error && (
{error}
)}
Вы выбрали связь между:
{newConnection.from || 'Сервер A'} {newConnection.to || 'Сервер B'}
setNewConnection({ ...newConnection, tunnelType: val })} required options={[ { value: 'GRE', label: 'GRE' }, { value: 'IPSec', label: 'IPSec' }, { value: 'WireGuard', label: 'WireGuard' }, { value: 'OpenVPN', label: 'OpenVPN' }, ]} />
setNewConnection({ ...newConnection, ipA: val })} placeholder="10.10.100.1" required />
setNewConnection({ ...newConnection, ipB: val })} placeholder="10.10.100.2" required />
Протяните линию между серверами в графе — здесь вы только уточняете параметры туннеля. Это делает построение схемы максимально быстрым и наглядным.

Добавить связь между серверами

setNewConnection({ ...newConnection, ipA: e.target.value })} placeholder="10.10.100.1" />
setNewConnection({ ...newConnection, ipB: e.target.value })} placeholder="10.10.100.2" />
{/* Список существующих связей */} {connections.length > 0 && (

Существующие связи ({connections.length})

{connections.map((connection, index) => { const serverA = servers.find(s => s.ip === connection.from); const serverB = servers.find(s => s.ip === connection.to); return ( ); })}
Сервер A Сервер B Тип туннеля IP адреса туннеля Действия
{connection.from}
{serverA?.dns || 'Неизвестный сервер'}
{connection.to}
{serverB?.dns || 'Неизвестный сервер'}
{connection.tunnelType} {connection.ipA} ⇄ {connection.ipB}
)} setConfirmState({ open: false, text: '', onConfirm: null })} onConfirm={() => confirmState.onConfirm?.()} /> ); } // Модальное окно для редактирования сервера с backdrop и анимацией function EditServerModal({ show, server, onChange, onSave, onClose }) { const modalRef = useRef(null); const initializedRef = useRef(false); // Локальное состояние для формы const [localServer, setLocalServer] = useState({}); // Функция для создания сервера с гарантированными gateways const ensureGateways = (srv) => { if (!srv) return { gateways: [] }; 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 && server && !initializedRef.current) { const prepared = ensureGateways(server); console.log('[EditServerModal] Инициализация с сервером:', prepared); setLocalServer(prepared); initializedRef.current = true; } // Сбрасываем флаг при закрытии if (!show) { initializedRef.current = false; } }, [show, server]); useEffect(() => { if (window.Tabler && window.Tabler.Modal && modalRef.current) { const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); if (show) { modalInstance.show(); } else { modalInstance.hide(); } // Закрытие по событию Tabler 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 => { const updated = { ...prev, [field]: value }; onChange && onChange(updated); return updated; }); }; // Функция обновления шлюза по индексу 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 ); const updated = { ...prev, gateways: updatedGateways }; onChange && onChange(updated); return updated; }); }; // Функция переключения primary шлюза 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 })); // Убедимся, что хотя бы один primary if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) { updatedGateways[idx].primary = true; } const updated = { ...prev, gateways: updatedGateways }; onChange && onChange(updated); return updated; }); }; // Функция добавления нового шлюза const handleAddGateway = () => { console.log('[EditServerModal] handleAddGateway вызвана, текущий localServer:', localServer); setLocalServer(prev => { const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : []; const newGw = makeGateway({ primary: currentGateways.length === 0 }); const updatedGateways = [...currentGateways, newGw]; const updated = { ...prev, gateways: updatedGateways }; console.log('[EditServerModal] Новый localServer после добавления шлюза:', updated); onChange && onChange(updated); return updated; }); }; // Функция удаления шлюза 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); // Убедимся, что есть primary if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) { updatedGateways[0].primary = true; } const updated = { ...prev, gateways: updatedGateways }; onChange && onChange(updated); return updated; }); }; // Получаем текущие шлюзы для рендера const currentGateways = Array.isArray(localServer.gateways) ? localServer.gateways : []; const showGateways = NEEDS_GATEWAYS(localServer.type); return (
Редактировать сервер
{ e.preventDefault(); onSave && onSave(localServer); }}>
handleFieldChange('id', e.target.value)} placeholder="srv-1" />
handleFieldChange('ip', e.target.value)} />
handleFieldChange('extIp', e.target.value)} placeholder="публичный IP, если отличается" />
handleFieldChange('internalIp', e.target.value)} placeholder="10.x.x.x / 192.168.x.x" />
handleFieldChange('dns', e.target.value)} />
handleFieldChange('country', e.target.value)} />
handleFieldChange('provider', e.target.value)} />
handleFieldChange('tunnel', e.target.value)} />
{showGateways && ( <>
Шлюзы ({currentGateways.length})
{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)} title="Основной шлюз" />
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
{currentGateways.length > 1 && ( )}
))}
Для jumphost/exit должен быть один основной gateway.
)}
); } function DeleteServerModal({ show, server, onDelete, onClose }) { const modalRef = useRef(null); 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]); return (
Подтверждение удаления

Вы уверены, что хотите удалить сервер {server?.ip}?

Это действие нельзя отменить.

); } // Модальное окно для генератора ссылок function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, onGenerateUrl, onCopyToClipboard, onClose }) { const modalRef = useRef(null); const [localUrlSettings, setLocalUrlSettings] = useState(urlSettings); // Синхронизируем локальное состояние с пропсами useEffect(() => { setLocalUrlSettings(urlSettings); }, [urlSettings]); 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 handleSettingChange = (key, value) => { const updated = { ...localUrlSettings, [key]: value }; setLocalUrlSettings(updated); onUrlSettingsChange(updated); }; const handleSaveSettings = () => { onUrlSettingsChange(localUrlSettings); // Сохраняем в localStorage localStorage.setItem('urlSettings', JSON.stringify(localUrlSettings)); }; const generatedUrl = server ? onGenerateUrl(server) : ''; return (
Генератор ссылок для {server?.ip}
Настройки URL
handleSettingChange('baseUrl', e.target.value)} placeholder="https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo" />
handleSettingChange('cloudflare_gateway', e.target.value)} placeholder="SWE-IHOR или IP" />
handleSettingChange('bunny_gateway', e.target.value)} placeholder="SWE-IHOR или IP" />
handleSettingChange('fastly_gateway', e.target.value)} placeholder="SWE-IHOR или IP" />
handleSettingChange('telegram_gateway', e.target.value)} placeholder="IP адрес" />
handleSettingChange('hetzner_gateway', e.target.value)} placeholder="IP адрес" />
handleSettingChange('type', e.target.value)} placeholder="routes" />
handleSettingChange('version', e.target.value)} placeholder="v4.rsc" />
Сгенерированная ссылка
); } // Модальное окно для добавления нового сервера function AddServerModal({ show, newServer, customProvider, onNewServerChange, onCustomProviderChange, onAddServer, onClose, error }) { const modalRef = useRef(null); 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 handleChange = (field, value) => { onNewServerChange({ ...newServer, [field]: value }); }; const handleSubmit = (e) => { e.preventDefault(); onAddServer(); }; return (
Добавить новый сервер
{error && (
{error}
)}
handleChange('ip', e.target.value)} required />
handleChange('dns', e.target.value)} required />
{newServer.provider === 'Другой' && ( onCustomProviderChange(e.target.value)} /> )}
handleChange('gateway', e.target.value)} required />
); } export default ServerManager;