From acd4e8043556494e812bad03c22ff5999e557260 Mon Sep 17 00:00:00 2001 From: shats Date: Tue, 20 Jan 2026 13:33:03 +0700 Subject: [PATCH] feat: Refactor ServerManager component to enhance server management features, including improved filtering, sorting, pagination, and bulk actions, while adding new server and connection management functionalities. --- frontend/src/ServerManager.jsx | 1705 +++++++++-------- .../components/server/ServerBulkActions.jsx | 120 ++ frontend/src/components/server/ServerCard.jsx | 178 ++ .../src/components/server/ServerSidebar.jsx | 297 +++ frontend/src/components/server/index.js | 3 + 5 files changed, 1455 insertions(+), 848 deletions(-) create mode 100644 frontend/src/components/server/ServerBulkActions.jsx create mode 100644 frontend/src/components/server/ServerCard.jsx create mode 100644 frontend/src/components/server/ServerSidebar.jsx diff --git a/frontend/src/ServerManager.jsx b/frontend/src/ServerManager.jsx index ba01030..4c1d936 100644 --- a/frontend/src/ServerManager.jsx +++ b/frontend/src/ServerManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +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'; @@ -22,7 +22,16 @@ import { IconLink, IconFilter, IconMapPin, - IconWorld + IconWorld, + IconList, + IconLayoutGrid, + IconNetwork, + IconGitBranch, + IconCopy, + IconSquare, + IconSquareCheck, + IconChevronRight, + IconSettings } from '@tabler/icons-react'; import GraphView from './GraphView'; import { @@ -33,15 +42,67 @@ import { } from './utils/serverUtils.js'; import { EditServerModal, - DeleteServerModal, - LinkGeneratorModal, - AddServerModal + LinkGeneratorModal, + ServerSidebar, + ServerCard, + ServerBulkActions } from './components/server/index.js'; 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 [servers, setServers] = useState([]); + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // === UI State === + const [activeTab, setActiveTab] = useState('servers'); // 'servers' | 'connections' + const [viewMode, setViewMode] = useState('cards'); // 'table' | 'cards' + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + + // === Фильтры и поиск === + 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 [newServer, setNewServer] = useState({ id: '', ip: '', @@ -55,32 +116,9 @@ function ServerManager() { 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 [customProvider, setCustomProvider] = useState(''); - const [editModalOpen, setEditModalOpen] = useState(false); - const [editModalServer, setEditModalServer] = useState(null); - - // Состояние для генератора ссылок - const [linkGeneratorOpen, setLinkGeneratorOpen] = useState(false); - const [linkGeneratorServer, setLinkGeneratorServer] = useState(null); + // === URL Settings === const [urlSettings, setUrlSettings] = useState({ baseUrl: 'https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo', cloudflare_gateway: 'SWE-IHOR', @@ -91,41 +129,33 @@ function ServerManager() { 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' } }; - // Состояние для мониторинга сети - 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 }); - + // === Helpers === 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) { @@ -167,39 +197,97 @@ function ServerManager() { } } 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; + // === Фильтрация и сортировка === + const filteredServers = useMemo(() => { + let result = [...servers]; + + // Применяем фильтры + if (filters.country) { + result = result.filter(s => s.country === filters.country); } - 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 })], + 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]); + + // === Пагинация === + const totalPages = Math.ceil(filteredServers.length / pageSize); + const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + // === 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 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 = () => { setAddServerModalOpen(true); }; @@ -239,7 +327,6 @@ function ServerManager() { newServer.gateways = []; } - // Проверяем, если выбран "Другой" провайдер, то customProvider должен быть заполнен if (newServer.provider === 'Другой' && !customProvider.trim()) { setError('Пожалуйста, введите название провайдера.'); return; @@ -247,7 +334,6 @@ function ServerManager() { setError(''); - // Используем customProvider если выбран "Другой" const finalProvider = newServer.provider === 'Другой' ? customProvider : newServer.provider; const nextId = `srv-${servers.length + 1}`; const normalizedGws = NEEDS_GATEWAYS(newServer.type) @@ -267,21 +353,7 @@ function ServerManager() { }; setServers([...servers, serverToAdd]); - setNewServer({ - id: '', - ip: '', - extIp: '', - internalIp: '', - dns: '', - country: '', - provider: '', - tunnel: 'GRE', - type: 'jumphost', - gateway: '', - gateways: [makeGateway({ primary: true })], - }); - setCustomProvider(''); - setAddServerModalOpen(false); + handleCloseAddServerModal(); setSuccess('Сервер успешно добавлен!'); setTimeout(() => setSuccess(''), 3000); }; @@ -312,6 +384,8 @@ function ServerManager() { })); setEditModalOpen(false); setEditModalServer(null); + setSuccess('Сервер успешно обновлён!'); + setTimeout(() => setSuccess(''), 3000); }; const handleEditModalClose = () => { @@ -319,7 +393,69 @@ function ServerManager() { setEditModalServer(null); }; - // Функции для генератора ссылок + const confirmDelete = (server) => { + setServerToDelete(server); + setShowDeleteModal(true); + }; + + const executeDelete = () => { + if (serverToDelete) { + setServers(servers.filter(s => s.ip !== serverToDelete.ip)); + setShowDeleteModal(false); + setServerToDelete(null); + setSuccess('Сервер удалён!'); + setTimeout(() => setSuccess(''), 3000); + } + }; + + // === 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 }); + setSuccess(`Удалено ${selectedServers.size} серверов. Не забудьте сохранить!`); + setTimeout(() => setSuccess(''), 3000); + }, + }); + }; + + const handleBulkChangeTunnel = (newTunnel) => { + if (selectedServers.size === 0) return; + setServers(servers.map(s => + selectedServers.has(s.ip) ? { ...s, tunnel: newTunnel } : s + )); + setSuccess(`Туннель изменён на ${newTunnel} для ${selectedServers.size} серверов. Не забудьте сохранить!`); + setTimeout(() => setSuccess(''), 3000); + }; + + 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); @@ -330,22 +466,6 @@ function ServerManager() { 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; @@ -355,7 +475,7 @@ function ServerManager() { setTimeout(() => setSuccess(''), 2000); }; - // Функция резервного копирования для http + // === Clipboard === const fallbackCopyToClipboard = (text) => { const textArea = document.createElement('textarea'); textArea.value = text; @@ -364,10 +484,10 @@ function ServerManager() { textArea.select(); try { document.execCommand('copy'); - setSuccess('Ссылка скопирована в буфер обмена!'); + setSuccess('Скопировано в буфер обмена!'); setTimeout(() => setSuccess(''), 3000); } catch (err) { - setError('Не удалось скопировать ссылку'); + setError('Не удалось скопировать'); } document.body.removeChild(textArea); }; @@ -376,11 +496,11 @@ function ServerManager() { if (navigator.clipboard) { try { await navigator.clipboard.writeText(text); - setSuccess('Ссылка скопирована в буфер обмена!'); + setSuccess('Скопировано в буфер обмена!'); setTimeout(() => setSuccess(''), 3000); return; } catch (err) { - // fallback ниже + // fallback } } fallbackCopyToClipboard(text); @@ -391,15 +511,11 @@ function ServerManager() { 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)); + // === Export === + const exportServersToCSV = (serversList) => { const csvContent = [ ['ID', 'IP', 'Ext IP', 'Internal IP', 'DNS', 'Country', 'Provider', 'Type', 'Tunnel', 'Generated URL'], - ...filtered.map(server => [ + ...serversList.map(server => [ server.id || '', server.ip, server.extIp || '', @@ -424,27 +540,11 @@ function ServerManager() { 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 exportAllLinks = () => { + exportServersToCSV(filteredServers); }; + // === Save === const handleSaveChanges = async () => { setLoading(true); try { @@ -452,115 +552,17 @@ function ServerManager() { setServers(normalizedServers); await api.post(`/servers`, { domains: normalizedServers }); await api.post(`/server-connections`, { domains: connections }); - setSuccess('Изменения по серверам и связям успешно сохранены!'); + setSuccess('Изменения успешно сохранены!'); setTimeout(() => setSuccess(''), 3000); } catch (error) { console.error('Error saving changes:', error); - setError('Не удалось сохранить изменения по серверам или связям.'); + 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()) - ); - } - - // Валидация и добавление связи (общая функция для формы и модального окна графа) + // === Connections === const addConnectionInternal = (draft) => { if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) { setError('Все поля для связи должны быть заполнены.'); @@ -577,7 +579,6 @@ function ServerManager() { return true; }; - // Добавление новой связи из нижней формы const handleAddConnection = () => { const ok = addConnectionInternal(newConnection); if (!ok) return; @@ -585,7 +586,6 @@ function ServerManager() { setError(''); }; - // Подтверждение связи, созданной через граф (модальное окно) const handleConfirmGraphConnection = () => { const ok = addConnectionInternal(newConnection); if (!ok) return; @@ -594,7 +594,6 @@ function ServerManager() { setError(''); }; - // Удаление связи const handleDeleteConnection = (index) => { setConnections(connections.filter((_, i) => i !== index)); setSuccess('Связь удалена!'); @@ -602,47 +601,68 @@ function ServerManager() { }; return ( - <> +
+ {/* Уведомления */} {error && ( -
+
-
- -
-
- {error} -
+ +
{error}
)} {success && ( -
+
-
- -
-
- {success} -
+ +
{success}
)} - {/* Page header */} + {/* Header */}
-

Серверы

-
Главная / Управление / Серверы
+

+ + Управление серверами +

+
+ {filteredServers.length} из {servers.length} серверов + {selectedServers.size > 0 && ( + • {selectedServers.size} выбрано + )} +
+ {/* Tabs */} +
+ + +
+ + {/* Actions */}
-
{Object.keys(presets).map(key => ( @@ -650,231 +670,412 @@ function ServerManager() { ))}
- - -
- {/* Таблица серверов на всю ширину */} -
-
-

Список серверов {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} - - - Онлайн - -
- - - + + {/* Servers list */} +
+ {/* Toolbar */} +
+
+
+
+ {/* Select all */} + + + {/* Sort dropdown */} +
+ +
+ {['ip', 'dns', 'country', 'provider', 'type', 'tunnel'].map(field => ( + + ))} +
-
-
- {/* Пагинация */} - {totalPages > 1 && ( -
-
- Показано {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length} -
-
    -
  • - -
  • -
  • - -
  • - {(() => { - const pages = []; - let start = Math.max(1, currentPage - 2); - let end = Math.min(totalPages, currentPage + 2); - if (currentPage <= 3) end = Math.min(totalPages, 5); - if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4); - if (start > 1) pages.push('start-ellipsis'); - for (let p = start; p <= end; p++) pages.push(p); - if (end < totalPages) pages.push('end-ellipsis'); - return pages.map((p) => ( - p === 'start-ellipsis' || p === 'end-ellipsis' ? ( -
  • - ) : ( -
  • - -
  • - ) - )); - })()} -
  • - -
  • -
  • - -
  • -
-
- )} -
+
- {/* Delete Modal - New unified component */} + {/* View mode toggle */} +
+ + +
+
+
+ + + {/* Content */} + {loading ? ( +
+
+
+
Загрузка серверов...
+
+
+ ) : filteredServers.length === 0 ? ( +
+
+ +

Серверы не найдены

+

Попробуйте изменить фильтры или добавьте новый сервер

+ +
+
+ ) : viewMode === 'cards' ? ( + /* Cards view */ +
+ {paginatedServers.map(server => ( + + ))} +
+ ) : ( + /* Table view */ +
+
+ + + + + + + + + + + + + + + {paginatedServers.map((server) => ( + + + + + + + + + + + ))} + +
+ + 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} +
+ + + +
+
+
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+
+ {(currentPage - 1) * pageSize + 1} – {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length} +
+
    +
  • + +
  • +
  • + +
  • + {[...Array(Math.min(5, totalPages))].map((_, i) => { + const pageNum = Math.max(1, Math.min(currentPage - 2, totalPages - 4)) + i; + if (pageNum > totalPages) return null; + return ( +
  • + +
  • + ); + })} +
  • + +
  • +
  • + +
  • +
+
+
+ )} + + + )} + + {/* Connections Tab */} + {activeTab === 'connections' && ( +
+ {/* Graph */} +
+
+

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

+
Поддержка: 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 || 'GRE', + })); + setGraphConnectionModalOpen(true); + }} + /> +
+
GRE
+
IPSec
+
WireGuard
+
OpenVPN
+
+
+
+ + {/* Add connection */} +
+
+

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

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + setNewConnection({ ...newConnection, ipA: e.target.value })} placeholder="10.10.100.1" /> +
+
+ + setNewConnection({ ...newConnection, ipB: e.target.value })} placeholder="10.10.100.2" /> +
+
+ +
+
+
+
+ + {/* Connections list */} + {connections.length > 0 && ( +
+
+

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

+
+
+ + + + + + + + + + + + {connections.map((conn, index) => { + const serverA = servers.find(s => s.ip === conn.from); + const serverB = servers.find(s => s.ip === conn.to); + return ( + + + + + + + + ); + })} + +
Сервер AСервер BТуннельIP адресаДействия
+
{conn.from}
+
{serverA?.dns || '—'}
+
+
{conn.to}
+
{serverB?.dns || '—'}
+
{conn.tunnelType}{conn.ipA} ⇄ {conn.ipB} + +
+
+
+ )} +
+ )} + + {/* Bulk Actions Bar */} + + + {/* Modals */} setShowDeleteModal(false)} @@ -893,7 +1094,6 @@ function ServerManager() { icon={IconTrash} /> - {/* Edit Server Modal */} - {/* Link Generator Modal */} - {/* Add Server Modal - New unified component */} + {/* Add Server Modal */} )} - setNewServer({ ...newServer, ip: val })} - placeholder="192.168.1.1" - required - icon={IconServer} - helpText="Введите IPv4 адрес сервера" - /> +
+
+ 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: 'Другой...' } + ]} + /> +
- setNewServer({ ...newServer, extIp: val })} - placeholder="Публичный IP, если отличается" - icon={IconServer} - /> + {newServer.provider === 'Другой' && ( +
+ +
+ )} - setNewServer({ ...newServer, internalIp: val })} - placeholder="10.x.x.x / 192.168.x.x" - icon={IconServer} - /> +
+ 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 + /> +
+
- 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 + placeholder="Имя шлюза" />
-
- +
{ const list = newServer.gateways.map((g, i) => i === idx ? { ...g, ip: e.target.value } : g); setNewServer({ ...newServer, gateways: list }); }} - placeholder="10.0.0.1" + placeholder="IP" />
-
- - { - const list = newServer.gateways.map((g, i) => i === idx ? { ...g, comment: e.target.value } : g); - setNewServer({ ...newServer, gateways: list }); - }} - placeholder="Основной шлюз" - /> -
-
-
+
+
g.primary)) list[idx].primary = true; setNewServer({ ...newServer, gateways: list }); }} - title="Основной шлюз" /> +
-
-
-
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
- {newServer.gateways.length > 1 && ( - - )} +
+ {newServer.gateways.length > 1 && ( + + )} +
))} @@ -1155,72 +1344,13 @@ function ServerManager() { }); }} > - - Добавить шлюз + Добавить шлюз -
Для 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
-
-
-
-
- {/* Модальное окно для быстрого создания связи после соединения узлов на графе */} + {/* Graph Connection Modal */} { @@ -1229,32 +1359,22 @@ function ServerManager() { setError(''); }} onSubmit={handleConfirmGraphConnection} - title="Создать связь между серверами" - submitLabel="Создать связь" + title="Создать связь" + submitLabel="Создать" submitIcon={IconPlus} size="md" > - {error && ( -
- {error} -
- )}
-
Вы выбрали связь между:
-
- - {newConnection.from || 'Сервер A'} - +
+ {newConnection.from || 'A'} - - {newConnection.to || 'Сервер B'} - + {newConnection.to || 'B'}
setNewConnection({ ...newConnection, ipA: val })} @@ -1280,7 +1400,7 @@ function ServerManager() {
setNewConnection({ ...newConnection, ipB: val })} @@ -1289,118 +1409,7 @@ function ServerManager() { />
-
- Протяните линию между серверами в графе — здесь вы только уточняете параметры туннеля. Это делает - построение схемы максимально быстрым и наглядным. -
-
-
-

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

-
-
-
-
- - -
-
- - -
-
- - -
-
- - 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?.()} /> - +
); } -export default ServerManager; \ No newline at end of file +export default ServerManager; diff --git a/frontend/src/components/server/ServerBulkActions.jsx b/frontend/src/components/server/ServerBulkActions.jsx new file mode 100644 index 0000000..90b1a13 --- /dev/null +++ b/frontend/src/components/server/ServerBulkActions.jsx @@ -0,0 +1,120 @@ +import { + IconTrash, + IconEdit, + IconCopy, + IconX, + IconNetwork, + IconDownload, + IconCheck +} from '@tabler/icons-react'; + +export default function ServerBulkActions({ + selectedCount, + onClearSelection, + onBulkDelete, + onBulkChangeTunnel, + onBulkExport, + onBulkCopyLinks, + tunnelTypes = ['GRE', 'IPSec', 'WireGuard', 'OpenVPN'] +}) { + if (selectedCount === 0) return null; + + return ( +
+
+ {/* Счётчик выбранных */} +
+ {selectedCount} + выбрано +
+ + {/* Действия */} +
+ {/* Смена типа туннеля */} +
+ +
+
Сменить тип туннеля
+ {tunnelTypes.map(tunnel => ( + + ))} +
+
+ + {/* Копировать ссылки */} + + + {/* Экспорт */} + + + {/* Удалить */} + + + {/* Отменить выделение */} + +
+
+ + +
+ ); +} diff --git a/frontend/src/components/server/ServerCard.jsx b/frontend/src/components/server/ServerCard.jsx new file mode 100644 index 0000000..9c55cff --- /dev/null +++ b/frontend/src/components/server/ServerCard.jsx @@ -0,0 +1,178 @@ +import { + IconServer, + IconWorld, + IconEdit, + IconTrash, + IconLink, + IconCopy, + IconChevronDown, + IconChevronUp, + IconNetwork, + IconMapPin, + IconCheck, + IconSquare, + IconSquareCheck +} from '@tabler/icons-react'; +import { useState } from 'react'; + +// Преобразование кода страны в emoji-флаг +function countryToFlag(isoCode) { + if (!isoCode) return ''; + return isoCode + .toUpperCase() + .replace(/./g, char => + String.fromCodePoint(127397 + char.charCodeAt()) + ); +} + +export default function ServerCard({ + server, + selected, + onSelect, + onEdit, + onDelete, + onGenerateLink, + onQuickCopy, + compact = false +}) { + const [expanded, setExpanded] = useState(false); + + const isExit = server.type === 'exit'; + const primaryGateway = server.gateways?.find(g => g.primary)?.name || server.gateway || '—'; + + return ( +
+
+ {/* Верхняя строка: checkbox + IP + тип + actions */} +
+
+ {/* Чекбокс выбора */} + + + {/* IP адрес */} +
+ {server.ip} +
+ + {/* Тип */} + + {isExit ? 'Exit' : 'Jumphost'} + + + {/* Страна */} + + {countryToFlag(server.country)} {server.country} + +
+ + {/* Действия */} +
+ + + + + +
+
+ + {/* Основная информация */} +
+
+ + {server.dns || '—'} +
+
+ + {server.provider} +
+
+ + {server.tunnel} +
+
+ + {primaryGateway} +
+
+ + {/* Расширенная информация */} + {expanded && ( +
+
+
+
Внешний IP
+ {server.extIp || '—'} +
+
+
Внутренний IP
+ {server.internalIp || '—'} +
+ {server.gateways?.length > 0 && ( +
+
Все шлюзы
+
+ {server.gateways.map((gw, idx) => ( + + {gw.name}{gw.ip ? ` (${gw.ip})` : ''} + + ))} +
+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/server/ServerSidebar.jsx b/frontend/src/components/server/ServerSidebar.jsx new file mode 100644 index 0000000..be59f75 --- /dev/null +++ b/frontend/src/components/server/ServerSidebar.jsx @@ -0,0 +1,297 @@ +import { useState, useMemo } from 'react'; +import { + IconServer, + IconWorld, + IconBuildingSkyscraper, + IconNetwork, + IconChevronDown, + IconChevronRight, + IconCheck, + IconX, + IconSearch, + IconFilter, + IconRefresh +} from '@tabler/icons-react'; + +// Преобразование кода страны в emoji-флаг +function countryToFlag(isoCode) { + if (!isoCode) return ''; + return isoCode + .toUpperCase() + .replace(/./g, char => + String.fromCodePoint(127397 + char.charCodeAt()) + ); +} + +export default function ServerSidebar({ + servers = [], + filters = {}, + onFiltersChange, + onSearch, + searchTerm = '', + onReset +}) { + const [expandedSections, setExpandedSections] = useState({ + country: true, + provider: true, + type: true, + tunnel: false + }); + + // Подсчёт серверов по категориям + const stats = useMemo(() => { + const byCountry = {}; + const byProvider = {}; + const byType = {}; + const byTunnel = {}; + + servers.forEach(srv => { + byCountry[srv.country] = (byCountry[srv.country] || 0) + 1; + byProvider[srv.provider] = (byProvider[srv.provider] || 0) + 1; + byType[srv.type] = (byType[srv.type] || 0) + 1; + byTunnel[srv.tunnel] = (byTunnel[srv.tunnel] || 0) + 1; + }); + + return { byCountry, byProvider, byType, byTunnel }; + }, [servers]); + + const toggleSection = (section) => { + setExpandedSections(prev => ({ + ...prev, + [section]: !prev[section] + })); + }; + + const handleFilterClick = (category, value) => { + const currentValue = filters[category]; + const newValue = currentValue === value ? '' : value; + onFiltersChange({ ...filters, [category]: newValue }); + }; + + const activeFiltersCount = Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0); + + const typeLabels = { + 'jumphost': 'Jumphost', + 'exit': 'Выходная нода' + }; + + return ( +
+ {/* Заголовок с кнопкой сброса */} +
+
+ + Фильтры + {activeFiltersCount > 0 && ( + {activeFiltersCount} + )} +
+ {activeFiltersCount > 0 && ( + + )} +
+ + {/* Поиск */} +
+
+ + + + onSearch(e.target.value)} + /> + {searchTerm && ( + onSearch('')} + style={{ right: 0 }} + > + + + )} +
+
+ + {/* Секция: Страны */} +
+
toggleSection('country')} + style={{ background: 'var(--tblr-bg-surface-secondary)' }} + > +
+ + Страны +
+ {expandedSections.country ? : } +
+ {expandedSections.country && ( +
+ {Object.entries(stats.byCountry) + .sort((a, b) => b[1] - a[1]) + .map(([country, count]) => ( + + ))} +
+ )} +
+ + {/* Секция: Провайдеры */} +
+
toggleSection('provider')} + style={{ background: 'var(--tblr-bg-surface-secondary)' }} + > +
+ + Провайдеры +
+ {expandedSections.provider ? : } +
+ {expandedSections.provider && ( +
+ {Object.entries(stats.byProvider) + .sort((a, b) => b[1] - a[1]) + .map(([provider, count]) => ( + + ))} +
+ )} +
+ + {/* Секция: Типы серверов */} +
+
toggleSection('type')} + style={{ background: 'var(--tblr-bg-surface-secondary)' }} + > +
+ + Тип сервера +
+ {expandedSections.type ? : } +
+ {expandedSections.type && ( +
+ {Object.entries(stats.byType) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ( + + ))} +
+ )} +
+ + {/* Секция: Туннели */} +
+
toggleSection('tunnel')} + style={{ background: 'var(--tblr-bg-surface-secondary)' }} + > +
+ + Тип туннеля +
+ {expandedSections.tunnel ? : } +
+ {expandedSections.tunnel && ( +
+ {Object.entries(stats.byTunnel) + .sort((a, b) => b[1] - a[1]) + .map(([tunnel, count]) => ( + + ))} +
+ )} +
+ + {/* Статистика */} +
+
+
+ Всего серверов: + {servers.length} +
+
+ Выходных нод: + {stats.byType['exit'] || 0} +
+
+ Jumphosts: + {stats.byType['jumphost'] || 0} +
+
+
+
+ ); +} diff --git a/frontend/src/components/server/index.js b/frontend/src/components/server/index.js index 11794db..c834de9 100644 --- a/frontend/src/components/server/index.js +++ b/frontend/src/components/server/index.js @@ -2,4 +2,7 @@ export { default as EditServerModal } from './EditServerModal.jsx'; export { default as DeleteServerModal } from './DeleteServerModal.jsx'; export { default as LinkGeneratorModal } from './LinkGeneratorModal.jsx'; export { default as AddServerModal } from './AddServerModal.jsx'; +export { default as ServerSidebar } from './ServerSidebar.jsx'; +export { default as ServerCard } from './ServerCard.jsx'; +export { default as ServerBulkActions } from './ServerBulkActions.jsx';