From d799c38edf79c0fc7bd6bc034054976b39960797 Mon Sep 17 00:00:00 2001 From: shats Date: Tue, 17 Feb 2026 10:37:23 +0700 Subject: [PATCH] feat(ServerManager): implement server grouping in cards view and enhance server modal for groupKey input --- frontend/src/ServerManager.jsx | 76 +++++++++-- .../src/components/server/ServerGroupCard.jsx | 129 ++++++++++++++++++ .../src/components/server/ServerModal.jsx | 22 +++ frontend/src/components/server/index.js | 1 + frontend/src/types/index.ts | 2 + 5 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/server/ServerGroupCard.jsx 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}
  • diff --git a/frontend/src/components/server/ServerGroupCard.jsx b/frontend/src/components/server/ServerGroupCard.jsx new file mode 100644 index 0000000..875b2c5 --- /dev/null +++ b/frontend/src/components/server/ServerGroupCard.jsx @@ -0,0 +1,129 @@ +import { + IconServer, + IconEdit, + IconTrash, + IconChevronDown, + IconChevronUp, + IconRouter, + IconCircleCheck, + IconSquare +} from '@tabler/icons-react'; +import { useState } from 'react'; + +function countryToFlag(isoCode) { + if (!isoCode) return ''; + const codeMap = { + 'SWE': 'SE', 'RUS': 'RU', 'USA': 'US', 'GER': 'DE', 'FIN': 'FI', 'NLD': 'NL' + }; + const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase(); + if (!code || code.length !== 2) return isoCode; + return code.replace(/./g, char => + String.fromCodePoint(127397 + char.charCodeAt()) + ); +} + +/** + * Одна карточка для группы серверов (например два home-роутера с одним groupKey — мультиван). + * Каждый сервер в группе сохраняет свой id; отображаются объединённо. + */ +export default function ServerGroupCard({ + groupKey, + servers, + selectedSet, + onSelect, + onEdit, + onDelete +}) { + const [expanded, setExpanded] = useState(true); + const title = groupKey || (servers[0]?.dns?.split('.')[0] || 'Группа'); + + return ( +
    +
    + {/* Заголовок группы */} +
    setExpanded(!expanded)} + > + + {title} + ({servers.length} WAN) + +
    + + {expanded && ( +
    + {servers.map((server) => { + const isSelected = selectedSet.has(server.ip); + const serverName = server.dns?.split('.')[0] || server.ip; + return ( +
    + + {countryToFlag(server.country)} +
    + {serverName} + {server.dns || server.ip} +
    +
    + + +
    +
    + ); + })} +
    + )} +
    +
    + ); +} diff --git a/frontend/src/components/server/ServerModal.jsx b/frontend/src/components/server/ServerModal.jsx index 35b22ff..d4c3aa4 100644 --- a/frontend/src/components/server/ServerModal.jsx +++ b/frontend/src/components/server/ServerModal.jsx @@ -455,6 +455,28 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
)} + {/* Группа объединения — для домашнего роутера с мультиваном: один ключ у нескольких записей = одна карточка в списке */} + {localServer.type === 'home' && ( +
+ + { + const v = e.target.value.trim(); + handleFieldChange('groupKey', v || ''); + }} + placeholder="Например: home-multivan — две записи с одним ключом отобразятся как один роутер" + /> +
+ Укажите одинаковый ключ у обоих WAN-записей одного роутера, чтобы в разделе «Серверы» они отображались одной карточкой. ID и привязки (интерфейсы, фильтры) не меняются. +
+
+ )} + {/* Основной шлюз (текстовое поле для совместимости) */}