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,
|
||||
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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface Server {
|
||||
type: ServerType;
|
||||
gateway?: string;
|
||||
gateways: Gateway[];
|
||||
/** Объединение в UI: серверы с одинаковым groupKey отображаются как один роутер (например мультиван). Зависимости (интерфейсы, gateways) по id не меняются. */
|
||||
groupKey?: string;
|
||||
}
|
||||
|
||||
export type ServerType = 'jumphost' | 'exit';
|
||||
|
||||
Reference in New Issue
Block a user