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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
|||||||
LinkGeneratorModal,
|
LinkGeneratorModal,
|
||||||
ServerSidebar,
|
ServerSidebar,
|
||||||
ServerCard,
|
ServerCard,
|
||||||
|
ServerGroupCard,
|
||||||
ServerBulkActions
|
ServerBulkActions
|
||||||
} from './components/server/index.js';
|
} from './components/server/index.js';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
@@ -217,9 +218,44 @@ function ServerManager() {
|
|||||||
return result;
|
return result;
|
||||||
}, [servers, filters, searchTerm, sortField, sortOrder]);
|
}, [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 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 ===
|
// === Handlers ===
|
||||||
const handleSort = (field) => {
|
const handleSort = (field) => {
|
||||||
@@ -252,7 +288,7 @@ function ServerManager() {
|
|||||||
if (selectedServers.size === filteredServers.length) {
|
if (selectedServers.size === filteredServers.length) {
|
||||||
setSelectedServers(new Set());
|
setSelectedServers(new Set());
|
||||||
} else {
|
} 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>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'cards' ? (
|
) : viewMode === 'cards' ? (
|
||||||
/* Cards view */
|
/* Cards view: одиночные серверы и группы (groupKey) */
|
||||||
<div>
|
<div>
|
||||||
{paginatedServers.map(server => (
|
{paginatedDisplayItems.map((item) =>
|
||||||
<ServerCard
|
item.type === 'group' ? (
|
||||||
key={server.id || server.ip}
|
<ServerGroupCard
|
||||||
server={server}
|
key={`group-${item.groupKey}`}
|
||||||
selected={selectedServers.has(server.ip)}
|
groupKey={item.groupKey}
|
||||||
onSelect={toggleServerSelection}
|
servers={item.servers}
|
||||||
onEdit={handleEdit}
|
selectedSet={selectedServers}
|
||||||
onDelete={confirmDelete}
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Table view */
|
/* Table view */
|
||||||
@@ -892,7 +940,7 @@ function ServerManager() {
|
|||||||
<div className="card mt-3">
|
<div className="card mt-3">
|
||||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
<div className="card-footer d-flex align-items-center justify-content-between">
|
||||||
<div className="text-muted">
|
<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>
|
</div>
|
||||||
<ul className="pagination m-0">
|
<ul className="pagination m-0">
|
||||||
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
<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>
|
</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">
|
<div className="col-12">
|
||||||
<label className="form-label">
|
<label className="form-label">
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export { default as LinkGeneratorModal } from './LinkGeneratorModal.jsx';
|
|||||||
export { default as AddServerModal } from './AddServerModal.jsx';
|
export { default as AddServerModal } from './AddServerModal.jsx';
|
||||||
export { default as ServerSidebar } from './ServerSidebar.jsx';
|
export { default as ServerSidebar } from './ServerSidebar.jsx';
|
||||||
export { default as ServerCard } from './ServerCard.jsx';
|
export { default as ServerCard } from './ServerCard.jsx';
|
||||||
|
export { default as ServerGroupCard } from './ServerGroupCard.jsx';
|
||||||
export { default as ServerBulkActions } from './ServerBulkActions.jsx';
|
export { default as ServerBulkActions } from './ServerBulkActions.jsx';
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface Server {
|
|||||||
type: ServerType;
|
type: ServerType;
|
||||||
gateway?: string;
|
gateway?: string;
|
||||||
gateways: Gateway[];
|
gateways: Gateway[];
|
||||||
|
/** Объединение в UI: серверы с одинаковым groupKey отображаются как один роутер (например мультиван). Зависимости (интерфейсы, gateways) по id не меняются. */
|
||||||
|
groupKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerType = 'jumphost' | 'exit';
|
export type ServerType = 'jumphost' | 'exit';
|
||||||
|
|||||||
Reference in New Issue
Block a user