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)
+ { e.stopPropagation(); setExpanded(!expanded); }}
+ title={expanded ? 'Свернуть' : 'Развернуть'}
+ >
+ {expanded ? : }
+
+
+
+ {expanded && (
+
+ {servers.map((server) => {
+ const isSelected = selectedSet.has(server.ip);
+ const serverName = server.dns?.split('.')[0] || server.ip;
+ return (
+
+
onSelect?.(server)}
+ title={isSelected ? 'Снять выбор' : 'Выбрать'}
+ >
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+
{countryToFlag(server.country)}
+
+ {serverName}
+ {server.dns || server.ip}
+
+
+ onEdit?.(server)}
+ >
+
+ Изменить
+
+ onDelete?.(server)}
+ >
+
+ Удалить
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
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 и привязки (интерфейсы, фильтры) не меняются.
+
+
+ )}
+
{/* Основной шлюз (текстовое поле для совместимости) */}
diff --git a/frontend/src/components/server/index.js b/frontend/src/components/server/index.js
index b5136a3..b6a2dd9 100644
--- a/frontend/src/components/server/index.js
+++ b/frontend/src/components/server/index.js
@@ -5,5 +5,6 @@ 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 ServerGroupCard } from './ServerGroupCard.jsx';
export { default as ServerBulkActions } from './ServerBulkActions.jsx';
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index cb84cb9..5bf6b52 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -24,6 +24,8 @@ export interface Server {
type: ServerType;
gateway?: string;
gateways: Gateway[];
+ /** Объединение в UI: серверы с одинаковым groupKey отображаются как один роутер (например мультиван). Зависимости (интерфейсы, gateways) по id не меняются. */
+ groupKey?: string;
}
export type ServerType = 'jumphost' | 'exit';