import { useState, useEffect, useRef, useMemo } from 'react'; import api from './lib/api.js'; import ConfirmDialog from './components/ConfirmDialog.jsx'; import ConfirmModal from './components/ConfirmModal.jsx'; import FormModal from './components/FormModal.jsx'; import FormField from './components/FormField.jsx'; import { useNotify } from './components/NotifyProvider.jsx'; import { IconPlus, IconEdit, IconTrash, IconDatabase, IconDownload, IconServer, IconFilter, IconList, IconLayoutGrid, IconGitBranch, IconSquare, IconSquareCheck, IconChevronRight } from '@tabler/icons-react'; import GraphView from './GraphView'; import { normalizeGateways, needsGateways as NEEDS_GATEWAYS, SERVER_TYPE_OPTIONS } from './utils/serverUtils.js'; import { ServerModal, LinkGeneratorModal, ServerSidebar, ServerCard, ServerGroupCard, ServerBulkActions } from './components/server/index.js'; import PageHeader from './components/PageHeader.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx'; const API_URL = '/api'; // Преобразование кода страны в emoji-флаг function countryToFlag(isoCode) { if (!isoCode) return ''; return isoCode .toUpperCase() .replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()) ); } function ServerManager() { const notify = useNotify(); // === Основные данные === const [servers, setServers] = useState([]); const [connections, setConnections] = useState([]); const [networkConfigGateways, setNetworkConfigGateways] = useState([]); const [loading, setLoading] = useState(false); // === UI State === const [activeTab, setActiveTab] = useState('servers'); // 'servers' | 'connections' const [viewMode, setViewMode] = useState('cards'); // 'table' | 'cards' // === Фильтры и поиск === const [filters, setFilters] = useState({ country: '', provider: '', type: '', tunnel: '' }); const [searchTerm, setSearchTerm] = useState(''); // === Сортировка и пагинация === const [sortField, setSortField] = useState('ip'); const [sortOrder, setSortOrder] = useState('asc'); const [currentPage, setCurrentPage] = useState(1); const pageSize = 20; // === Выбор (bulk selection) === const [selectedServers, setSelectedServers] = useState(new Set()); // === Модальные окна === const [addServerModalOpen, setAddServerModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); const [editModalServer, setEditModalServer] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); const [serverToDelete, setServerToDelete] = useState(null); const [linkGeneratorOpen, setLinkGeneratorOpen] = useState(false); const [linkGeneratorServer, setLinkGeneratorServer] = useState(null); const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false); const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null }); // === Ошибки форм === const [modalError, setModalError] = useState(''); // === URL Settings === 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 [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' }); // === Фильтры графа === const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true }); const [graphCountryFilter, setGraphCountryFilter] = useState(''); const [graphProviderFilter, setGraphProviderFilter] = useState(''); 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' } }; // === Helpers === const normalizeServerType = (type) => { const value = String(type || '').toLowerCase(); return SERVER_TYPE_OPTIONS.some((opt) => opt.value === value) ? value : 'jumphost'; }; // === Загрузка данных === const fetchNetworkConfig = async (abortSignal) => { const opts = abortSignal ? { signal: abortSignal } : {}; try { const res = await api.get('/network-config', opts); const config = res.data || {}; setNetworkConfigGateways(Array.isArray(config.gateways) ? config.gateways : []); } catch (e) { if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return; console.warn('Network config not loaded:', e?.message || e); setNetworkConfigGateways([]); } }; useEffect(() => { const controller = new AbortController(); const signal = controller.signal; fetchServers(signal); fetchConnections(signal); fetchNetworkConfig(signal); return () => controller.abort(); }, []); 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 (abortSignal) => { setLoading(true); const opts = abortSignal ? { signal: abortSignal } : {}; try { const response = await api.get(`/servers`, opts); 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); } catch (error) { if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; console.error('Error fetching servers:', error); notify.error('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.'); } finally { setLoading(false); } }; const fetchConnections = async (abortSignal) => { const opts = abortSignal ? { signal: abortSignal } : {}; try { const response = await api.get(`/server-connections`, opts); if (Array.isArray(response.data)) { setConnections(response.data); } else { setConnections([]); } } catch (error) { if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; console.error('Error fetching server connections:', error); } }; // Шлюзы для карточки: из /network-config по serverId, иначе из server.gateways const getGatewaysForServer = useMemo(() => { const gatewaysByServerId = {}; (networkConfigGateways || []).forEach((gw) => { if (!gw.serverId) return; if (!gatewaysByServerId[gw.serverId]) gatewaysByServerId[gw.serverId] = []; gatewaysByServerId[gw.serverId].push(gw); }); return (server) => { const ids = [server?.id, server?.ip, server?.dns].filter(Boolean); for (const id of ids) { const gws = gatewaysByServerId[id]; if (gws?.length > 0) { return gws.map((gw, idx) => ({ id: gw.id, name: gw.description || gw.id || gw.ip, ip: gw.ip, comment: gw.description, primary: idx === 0, })); } } return server?.gateways || []; }; }, [networkConfigGateways]); // === Фильтрация и сортировка === const filteredServers = useMemo(() => { let result = [...servers]; // Применяем фильтры if (filters.country) { result = result.filter(s => s.country === filters.country); } if (filters.provider) { result = result.filter(s => s.provider === filters.provider); } if (filters.type) { result = result.filter(s => s.type === filters.type); } if (filters.tunnel) { result = result.filter(s => s.tunnel === filters.tunnel); } // Поиск if (searchTerm) { const term = searchTerm.toLowerCase(); result = result.filter(s => s.ip?.toLowerCase().includes(term) || s.dns?.toLowerCase().includes(term) || s.country?.toLowerCase().includes(term) || s.provider?.toLowerCase().includes(term) || (s.type || '').toLowerCase().includes(term) ); } // Сортировка result.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; }); return result; }, [servers, filters, searchTerm, sortField, sortOrder]); // Элементы для отображения: серверы с одинаковым groupKey (и type home) объединяются в одну карточку const displayItems = useMemo(() => { const keyToServers = {}; filteredServers.forEach((s) => { const k = (s.groupKey && String(s.groupKey).trim()) || null; if (k) { if (!keyToServers[k]) keyToServers[k] = []; keyToServers[k].push(s); } }); const seenKeys = new Set(); const items = []; for (const s of filteredServers) { const k = (s.groupKey && String(s.groupKey).trim()) || null; if (!k) { items.push({ type: 'server', server: s }); } else { if (!seenKeys.has(k)) { seenKeys.add(k); const group = keyToServers[k]; items.push(group.length >= 2 ? { type: 'group', groupKey: k, servers: group } : { type: 'server', server: group[0] }); } } } return items; }, [filteredServers]); // === Пагинация === const totalPagesCards = Math.ceil(displayItems.length / pageSize); const totalPagesTable = Math.ceil(filteredServers.length / pageSize); const totalPages = viewMode === 'cards' ? totalPagesCards : totalPagesTable; const paginatedDisplayItems = displayItems.slice((currentPage - 1) * pageSize, currentPage * pageSize); const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize); const totalItemsCount = viewMode === 'cards' ? displayItems.length : filteredServers.length; useEffect(() => { if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1); }, [viewMode, totalPages]); // === Handlers === const handleSort = (field) => { if (sortField === field) { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortOrder('asc'); } }; const handleResetFilters = () => { setFilters({ country: '', provider: '', type: '', tunnel: '' }); setSearchTerm(''); setCurrentPage(1); }; // === Selection === const toggleServerSelection = (server) => { const newSet = new Set(selectedServers); if (newSet.has(server.ip)) { newSet.delete(server.ip); } else { newSet.add(server.ip); } setSelectedServers(newSet); }; const toggleGroupSelection = (servers, selectAll) => { setSelectedServers((prev) => { const next = new Set(prev); servers.forEach((s) => { if (selectAll) next.add(s.ip); else next.delete(s.ip); }); return next; }); }; const selectAllServers = () => { if (selectedServers.size === filteredServers.length) { setSelectedServers(new Set()); } else { setSelectedServers(new Set(filteredServers.map((s) => s.ip))); } }; const clearSelection = () => { setSelectedServers(new Set()); }; // === CRUD Operations === const handleOpenAddServerModal = () => { setModalError(''); setAddServerModalOpen(true); }; const handleCloseAddServerModal = () => { setAddServerModalOpen(false); setModalError(''); }; const handleAddServerFromModal = (serverData) => { const nextId = `srv-${servers.length + 1}`; const serverToAdd = { ...serverData, id: serverData.id || nextId, type: normalizeServerType(serverData.type), }; setServers([...servers, serverToAdd]); handleCloseAddServerModal(); // Автоматически создаём запись в биллинге (async () => { try { const billingRes = await api.get(`/billing`); const billingData = Array.isArray(billingRes.data) ? billingRes.data : []; // Проверяем, нет ли уже такого сервера в биллинге const alreadyExists = billingData.some(b => b.serverId === serverToAdd.id); if (!alreadyExists) { const newBillingItem = { id: `billing-${serverToAdd.id}`, hostName: serverToAdd.dns || serverToAdd.ip, purpose: '', country: serverToAdd.country || '', provider: serverToAdd.provider || '', loginUrl: '', monthlyCost: 0, monthlyCostCurrency: 'USD', nextPaymentDate: '', lastPaymentDate: '', lastPaymentAmount: 0, lastPaymentCurrency: 'USD', status: 'active', notes: 'Автоматически добавлен из серверов', serverId: serverToAdd.id, payments: [] }; await api.post(`/billing`, { domains: [...billingData, newBillingItem] }); } } catch (e) { console.warn('Не удалось создать запись биллинга:', e); } })(); notify.success('Сервер успешно добавлен!'); }; const handleEdit = (server) => { setModalError(''); setEditModalServer({ ...server }); setEditModalOpen(true); }; const handleEditModalSave = (updatedServer) => { const normalizedServer = { ...updatedServer, type: normalizeServerType(updatedServer.type), }; 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); notify.success('Сервер успешно обновлён!'); }; const handleEditModalClose = () => { setEditModalOpen(false); setEditModalServer(null); setModalError(''); }; const confirmDelete = (server) => { setServerToDelete(server); setShowDeleteModal(true); }; const executeDelete = () => { if (serverToDelete) { setServers(servers.filter(s => s.ip !== serverToDelete.ip)); setShowDeleteModal(false); setServerToDelete(null); notify.success('Сервер удалён!'); } }; // === Bulk Actions === const handleBulkDelete = () => { if (selectedServers.size === 0) return; setConfirmState({ open: true, text: `Вы уверены, что хотите удалить ${selectedServers.size} сервер(ов)? Это действие необратимо.`, onConfirm: () => { setServers(servers.filter(s => !selectedServers.has(s.ip))); setSelectedServers(new Set()); setConfirmState({ open: false, text: '', onConfirm: null }); notify.success(`Удалено ${selectedServers.size} серверов. Не забудьте сохранить!`); }, }); }; const handleBulkChangeTunnel = (newTunnel) => { if (selectedServers.size === 0) return; setServers(servers.map(s => selectedServers.has(s.ip) ? { ...s, tunnel: newTunnel } : s )); notify.success(`Туннель изменён на ${newTunnel} для ${selectedServers.size} серверов. Не забудьте сохранить!`); }; const handleBulkExport = () => { const selected = servers.filter(s => selectedServers.has(s.ip)); exportServersToCSV(selected); }; const handleBulkCopyLinks = async () => { const selected = servers.filter(s => selectedServers.has(s.ip)); const links = selected.map(s => generateServerUrl(s)).join('\n'); await copyToClipboard(links); }; // === URL Generation === const generateServerUrl = (server) => { const params = new URLSearchParams(); Object.entries(urlSettings).forEach(([key, value]) => { if (key !== 'baseUrl' && value) { params.append(key, value); } }); params.append('server', server.ip); return `${urlSettings.baseUrl}?${params.toString()}`; }; const handleGenerateLink = (server) => { setLinkGeneratorServer(server); setLinkGeneratorOpen(true); }; const handleLinkGeneratorClose = () => { setLinkGeneratorOpen(false); setLinkGeneratorServer(null); }; const applyPreset = (name) => { const p = presets[name]; if (!p) return; setUrlSettings(prev => ({ ...prev, ...p })); localStorage.setItem('urlSettings', JSON.stringify({ ...urlSettings, ...p })); notify.success('Профиль применён'); }; // === Clipboard === const fallbackCopyToClipboard = (text) => { const textArea = document.createElement('textarea'); textArea.value = text; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand('copy'); notify.success('Скопировано в буфер обмена!'); } catch (err) { notify.error('Не удалось скопировать'); } document.body.removeChild(textArea); }; const copyToClipboard = async (text) => { if (navigator.clipboard) { try { await navigator.clipboard.writeText(text); notify.success('Скопировано в буфер обмена!'); return; } catch (err) { // fallback } } fallbackCopyToClipboard(text); }; const quickCopyLink = async (server) => { const url = generateServerUrl(server); await copyToClipboard(url); }; // === Export === const exportServersToCSV = (serversList) => { const csvContent = [ ['ID', 'IP', 'Ext IP', 'Internal IP', 'DNS', 'Country', 'Provider', 'Type', 'Tunnel', 'Generated URL'], ...serversList.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 exportAllLinks = () => { exportServersToCSV(filteredServers); }; // === Save === 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 }); // Синхронизация с биллингом: добавляем новые серверы try { const billingRes = await api.get(`/billing`); const billingData = Array.isArray(billingRes.data) ? billingRes.data : []; // Создаём Set из существующих serverId в биллинге const existingServerIds = new Set( billingData.map(b => b.serverId).filter(Boolean) ); // Находим серверы, которых нет в биллинге const newBillingItems = normalizedServers .filter(server => server.id && !existingServerIds.has(server.id)) .map(server => ({ id: `billing-${server.id}`, 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: [] })); if (newBillingItems.length > 0) { const updatedBilling = [...billingData, ...newBillingItems]; await api.post(`/billing`, { domains: updatedBilling }); console.log(`Добавлено ${newBillingItems.length} записей в биллинг`); } } catch (billingError) { console.warn('Не удалось синхронизировать биллинг:', billingError); // Не прерываем основную операцию } notify.success('Изменения успешно сохранены!'); } catch (error) { console.error('Error saving changes:', error); notify.error('Не удалось сохранить изменения.'); } finally { setLoading(false); } }; const handleRefreshAll = () => { fetchServers(); fetchConnections(); fetchNetworkConfig(); }; // === Connections === const addConnectionInternal = (draft) => { if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) { notify.error('Все поля для связи должны быть заполнены.'); return false; } if (draft.from === draft.to) { notify.error('Сервер A и сервер B не могут быть одинаковыми.'); return false; } const next = { ...draft }; setConnections((prev) => [...prev, next]); notify.success('Связь успешно добавлена!'); return true; }; const handleAddConnection = () => { const ok = addConnectionInternal(newConnection); if (!ok) return; setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' }); }; const handleConfirmGraphConnection = () => { const ok = addConnectionInternal(newConnection); if (!ok) return; setGraphConnectionModalOpen(false); setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' }); }; const handleDeleteConnection = (index) => { setConnections(connections.filter((_, i) => i !== index)); notify.success('Связь удалена!'); }; return (
Попробуйте изменить фильтры или добавьте новый сервер
| handleSort('ip')}> IP {sortField === 'ip' && (sortOrder === 'asc' ? '▲' : '▼')} | handleSort('type')}> Тип {sortField === 'type' && (sortOrder === 'asc' ? '▲' : '▼')} | 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' ? '▲' : '▼')} | Действия | |
|---|---|---|---|---|---|---|---|
{server.ip} |
{server.type === 'exit' ? 'Exit' : 'Jumphost'} | {server.dns} | {countryToFlag(server.country)} {server.country} | {server.provider} | {server.tunnel} |
|
| Сервер A | Сервер B | Туннель | IP адреса | Действия |
|---|---|---|---|---|
|
{conn.from}
{serverA?.dns || '—'}
|
{conn.to}
{serverB?.dns || '—'}
|
{conn.tunnelType} | {conn.ipA} ⇄ {conn.ipB} |
Вы уверены, что хотите удалить сервер {serverToDelete.ip}?
Это действие нельзя отменить.
> ) : null } confirmLabel="Удалить" variant="danger" icon={IconTrash} /> {/* Единое модальное окно для редактирования */}