diff --git a/frontend/src/ServerManager.jsx b/frontend/src/ServerManager.jsx index 1b85a11..29ab78b 100644 --- a/frontend/src/ServerManager.jsx +++ b/frontend/src/ServerManager.jsx @@ -31,6 +31,7 @@ import { LinkGeneratorModal, ServerSidebar, ServerCard, + ServerGroupCard, ServerBulkActions } from './components/server/index.js'; import PageHeader from './components/PageHeader.jsx'; @@ -217,9 +218,44 @@ function ServerManager() { 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 totalPages = Math.ceil(filteredServers.length / pageSize); + 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) => { @@ -252,7 +288,7 @@ function ServerManager() { if (selectedServers.size === filteredServers.length) { setSelectedServers(new Set()); } else { - setSelectedServers(new Set(filteredServers.map(s => s.ip))); + setSelectedServers(new Set(filteredServers.map((s) => s.ip))); } }; @@ -792,18 +828,30 @@ function ServerManager() { ) : viewMode === 'cards' ? ( - /* Cards view */ + /* Cards view: одиночные серверы и группы (groupKey) */
- {paginatedServers.map(server => ( - - ))} + {paginatedDisplayItems.map((item) => + item.type === 'group' ? ( + + ) : ( + + ) + )}
) : ( /* Table view */ @@ -892,7 +940,7 @@ function ServerManager() {
- {(currentPage - 1) * pageSize + 1} – {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length} + {(currentPage - 1) * pageSize + 1} – {Math.min(currentPage * pageSize, totalItemsCount)} из {totalItemsCount}
)} + {/* Группа объединения — для домашнего роутера с мультиваном: один ключ у нескольких записей = одна карточка в списке */} + {localServer.type === 'home' && ( +
+ + { + const v = e.target.value.trim(); + handleFieldChange('groupKey', v || ''); + }} + placeholder="Например: home-multivan — две записи с одним ключом отобразятся как один роутер" + /> +
+ Укажите одинаковый ключ у обоих WAN-записей одного роутера, чтобы в разделе «Серверы» они отображались одной карточкой. ID и привязки (интерфейсы, фильтры) не меняются. +
+
+ )} + {/* Основной шлюз (текстовое поле для совместимости) */}