feat(ServerManager): implement server grouping in cards view and enhance server modal for groupKey input
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s

This commit is contained in:
2026-02-17 10:37:23 +07:00
parent 29fcee8ab1
commit d799c38edf
5 changed files with 216 additions and 14 deletions
+62 -14
View File
@@ -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() {
</div>
</div>
) : viewMode === 'cards' ? (
/* Cards view */
/* Cards view: одиночные серверы и группы (groupKey) */
<div>
{paginatedServers.map(server => (
<ServerCard
key={server.id || server.ip}
server={server}
selected={selectedServers.has(server.ip)}
onSelect={toggleServerSelection}
onEdit={handleEdit}
onDelete={confirmDelete}
/>
))}
{paginatedDisplayItems.map((item) =>
item.type === 'group' ? (
<ServerGroupCard
key={`group-${item.groupKey}`}
groupKey={item.groupKey}
servers={item.servers}
selectedSet={selectedServers}
onSelect={toggleServerSelection}
onEdit={handleEdit}
onDelete={confirmDelete}
/>
) : (
<ServerCard
key={item.server.id || item.server.ip}
server={item.server}
selected={selectedServers.has(item.server.ip)}
onSelect={toggleServerSelection}
onEdit={handleEdit}
onDelete={confirmDelete}
/>
)
)}
</div>
) : (
/* Table view */
@@ -892,7 +940,7 @@ function ServerManager() {
<div className="card mt-3">
<div className="card-footer d-flex align-items-center justify-content-between">
<div className="text-muted">
{(currentPage - 1) * pageSize + 1} {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length}
{(currentPage - 1) * pageSize + 1} {Math.min(currentPage * pageSize, totalItemsCount)} из {totalItemsCount}
</div>
<ul className="pagination m-0">
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
@@ -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 (
<div
className="server-group-card card mb-2 border-primary-lt"
style={{
borderRadius: '12px',
borderWidth: '1px',
borderLeftWidth: '4px',
borderLeftColor: 'var(--tblr-primary)'
}}
>
<div className="card-body p-0">
{/* Заголовок группы */}
<div
className="d-flex align-items-center px-3 py-2 bg-primary-lt"
style={{
borderTopLeftRadius: '11px',
borderTopRightRadius: '11px',
cursor: 'pointer'
}}
onClick={() => setExpanded(!expanded)}
>
<IconRouter size={20} className="text-primary me-2" />
<span className="fw-semibold text-primary">{title}</span>
<span className="text-muted ms-2">({servers.length} WAN)</span>
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm ms-auto"
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
title={expanded ? 'Свернуть' : 'Развернуть'}
>
{expanded ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
</button>
</div>
{expanded && (
<div className="p-2">
{servers.map((server) => {
const isSelected = selectedSet.has(server.ip);
const serverName = server.dns?.split('.')[0] || server.ip;
return (
<div
key={server.id || server.ip}
className={`d-flex align-items-center rounded p-2 mb-1 ${isSelected ? 'bg-primary-lt' : 'bg-light'}`}
>
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm p-0 me-2"
onClick={() => onSelect?.(server)}
title={isSelected ? 'Снять выбор' : 'Выбрать'}
>
{isSelected ? (
<IconCircleCheck size={18} className="text-primary" />
) : (
<IconSquare size={18} className="text-muted" />
)}
</button>
<span className="me-2" style={{ fontSize: '1.1rem' }}>{countryToFlag(server.country)}</span>
<div className="flex-grow-1 min-w-0">
<span className="fw-medium">{serverName}</span>
<span className="text-muted ms-2 small">{server.dns || server.ip}</span>
</div>
<div className="btn-list gap-1">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
title="Редактировать"
onClick={() => onEdit?.(server)}
>
<IconEdit size={14} className="me-1" />
Изменить
</button>
<button
type="button"
className="btn btn-outline-danger btn-sm"
title="Удалить"
onClick={() => onDelete?.(server)}
>
<IconTrash size={14} className="me-1" />
Удалить
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -455,6 +455,28 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
</div>
)}
{/* Группа объединения — для домашнего роутера с мультиваном: один ключ у нескольких записей = одна карточка в списке */}
{localServer.type === 'home' && (
<div className="col-12">
<label className="form-label">
Группа (объединение в списке)
</label>
<input
type="text"
className="form-control"
value={localServer.groupKey || ''}
onChange={(e) => {
const v = e.target.value.trim();
handleFieldChange('groupKey', v || '');
}}
placeholder="Например: home-multivan — две записи с одним ключом отобразятся как один роутер"
/>
<div className="form-text">
Укажите одинаковый ключ у обоих WAN-записей одного роутера, чтобы в разделе «Серверы» они отображались одной карточкой. ID и привязки (интерфейсы, фильтры) не меняются.
</div>
</div>
)}
{/* Основной шлюз (текстовое поле для совместимости) */}
<div className="col-12">
<label className="form-label">
+1
View File
@@ -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';
+2
View File
@@ -24,6 +24,8 @@ export interface Server {
type: ServerType;
gateway?: string;
gateways: Gateway[];
/** Объединение в UI: серверы с одинаковым groupKey отображаются как один роутер (например мультиван). Зависимости (интерфейсы, gateways) по id не меняются. */
groupKey?: string;
}
export type ServerType = 'jumphost' | 'exit';