feat(ServerCard): улучшена компоновка карточки сервера - визуальные группы
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s

This commit is contained in:
2026-01-20 14:13:32 +07:00
parent 75f44d2c4b
commit 28a9d0efa8
+160 -176
View File
@@ -6,10 +6,13 @@ import {
IconCopy,
IconChevronDown,
IconChevronUp,
IconNetwork,
IconCheck,
IconCircleCheck,
IconExternalLink
IconWorld,
IconNetwork,
IconCloud,
IconRouter,
IconArrowsRightLeft
} from '@tabler/icons-react';
import { useState } from 'react';
@@ -46,195 +49,176 @@ export default function ServerCard({
const isExit = server.type === 'exit';
const primaryGateway = server.gateways?.find(g => g.primary)?.name || server.gateway || '';
const connectionsCount = server.gateways?.length || 0;
const shortName = server.dns?.split('.')[0] || server.ip?.split('.').slice(0, 2).join('.');
const serverName = server.dns?.split('.')[0] || server.ip;
return (
<div
className={`server-row card mb-2 ${selected ? 'border-primary border-2' : ''}`}
className={`server-card card mb-2 ${selected ? 'border-primary' : ''}`}
style={{
borderRadius: '10px',
overflow: 'hidden',
borderRadius: '8px',
borderWidth: selected ? '2px' : '1px',
transition: 'all 0.15s ease'
}}
>
{/* Основная строка */}
<div
className="d-flex align-items-center px-3"
style={{ height: '52px' }}
>
{/* Статус онлайн */}
<div
className="rounded-circle bg-success flex-shrink-0"
style={{ width: 10, height: 10, marginRight: '12px' }}
title="Онлайн"
/>
{/* Чекбокс выбора */}
<button
className="btn p-0 border-0 bg-transparent me-3 flex-shrink-0"
onClick={(e) => { e.stopPropagation(); onSelect?.(server); }}
style={{ lineHeight: 1 }}
>
{selected ? (
<IconCircleCheck size={20} className="text-primary" />
) : (
<div
style={{
width: 18,
height: 18,
border: '2px solid #d1d5db',
borderRadius: '50%'
}}
/>
)}
</button>
{/* Количество соединений */}
<span className="badge bg-green-lt text-green me-3 flex-shrink-0">
<IconNetwork size={12} className="me-1" />
{connectionsCount}
</span>
{/* Флаг + Страна */}
<span className="me-2 flex-shrink-0" style={{ fontSize: '1.1rem' }}>
{countryToFlag(server.country)}
</span>
{/* Название */}
<span
className="fw-semibold me-3 flex-shrink-0"
style={{
fontSize: '0.9rem',
minWidth: '80px',
maxWidth: '120px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{shortName}
</span>
{/* Тип сервера */}
<span className={`badge me-3 flex-shrink-0 ${isExit ? 'bg-red-lt text-red' : 'bg-blue-lt text-blue'}`}>
{isExit ? 'EXIT' : 'JUMP'}
</span>
{/* Провайдер */}
<span className="badge bg-secondary-lt text-secondary me-3 flex-shrink-0">
{server.provider}
</span>
{/* Туннель */}
<span className="badge bg-teal-lt text-teal me-3 flex-shrink-0">
{server.tunnel}
</span>
{/* DNS */}
<div className="d-flex align-items-center text-muted flex-grow-1 me-2" style={{ minWidth: 0 }}>
<IconExternalLink size={13} style={{ opacity: 0.4, flexShrink: 0 }} className="me-1" />
<span
<div className="card-body p-0">
{/* Основная строка */}
<div className="d-flex align-items-center">
{/* Левая часть: статус + выбор + тип */}
<div
className={`d-flex align-items-center justify-content-center px-3 py-3 ${isExit ? 'bg-red-lt' : 'bg-blue-lt'}`}
style={{
fontSize: '0.8rem',
fontFamily: 'ui-monospace, monospace',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
minWidth: '100px',
borderTopLeftRadius: selected ? '6px' : '7px',
borderBottomLeftRadius: expanded ? '0' : (selected ? '6px' : '7px')
}}
>
{server.dns}
</span>
</div>
<button
className="btn p-0 border-0 bg-transparent me-2"
onClick={(e) => { e.stopPropagation(); onSelect?.(server); }}
>
{selected ? (
<IconCircleCheck size={18} className={isExit ? 'text-red' : 'text-blue'} />
) : (
<div style={{ width: 16, height: 16, border: `2px solid ${isExit ? '#d63939' : '#206bc4'}`, borderRadius: '50%', opacity: 0.5 }} />
)}
</button>
<div className="text-center">
{isExit ? (
<IconArrowsRightLeft size={18} className="text-red" />
) : (
<IconRouter size={18} className="text-blue" />
)}
<div className={`fw-bold small ${isExit ? 'text-red' : 'text-blue'}`} style={{ fontSize: '0.7rem', marginTop: '2px' }}>
{isExit ? 'EXIT' : 'JUMP'}
</div>
</div>
</div>
{/* Gateway */}
{primaryGateway && (
<span className="badge bg-cyan-lt text-cyan me-3 flex-shrink-0">
<IconServer size={11} className="me-1" />
{primaryGateway}
</span>
)}
{/* Действия */}
<div className="d-flex align-items-center gap-1 flex-shrink-0">
<button
className="btn btn-ghost-primary btn-icon btn-sm"
title="Скопировать ссылку"
onClick={(e) => { e.stopPropagation(); onQuickCopy?.(server); }}
>
<IconCopy size={16} />
</button>
<button
className="btn btn-ghost-secondary btn-icon btn-sm"
title="Генератор ссылок"
onClick={(e) => { e.stopPropagation(); onGenerateLink?.(server); }}
>
<IconLink size={16} />
</button>
<button
className="btn btn-ghost-secondary btn-icon btn-sm"
title="Редактировать"
onClick={(e) => { e.stopPropagation(); onEdit?.(server); }}
>
<IconEdit size={16} />
</button>
<button
className="btn btn-ghost-danger btn-icon btn-sm"
title="Удалить"
onClick={(e) => { e.stopPropagation(); onDelete?.(server); }}
>
<IconTrash size={16} />
</button>
<button
className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={() => setExpanded(!expanded)}
title={expanded ? 'Свернуть' : 'Развернуть'}
>
{expanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
</button>
</div>
</div>
{/* Расширенная информация */}
{expanded && (
<div className="card-body pt-0 border-top">
<div className="row g-3 pt-3">
<div className="col-md-3">
<div className="text-muted small mb-1">IP адрес</div>
<code>{server.ip}</code>
</div>
<div className="col-md-3">
<div className="text-muted small mb-1">Внешний IP</div>
<code>{server.extIp || '—'}</code>
</div>
<div className="col-md-3">
<div className="text-muted small mb-1">Внутренний IP</div>
<code>{server.internalIp || '—'}</code>
</div>
<div className="col-md-3">
<div className="text-muted small mb-1">Страна</div>
<span>{countryToFlag(server.country)} {server.country}</span>
</div>
{server.gateways?.length > 0 && (
<div className="col-12">
<div className="text-muted small mb-2">Шлюзы ({server.gateways.length})</div>
<div className="d-flex flex-wrap gap-2">
{server.gateways.map((gw, idx) => (
<span
key={gw.id || idx}
className={`badge ${gw.primary ? 'bg-azure-lt text-azure' : 'bg-secondary-lt text-secondary'}`}
title={gw.comment || (gw.primary ? 'Основной' : 'Резервный')}
>
{gw.primary && <IconCheck size={11} className="me-1" />}
{gw.name}
{gw.ip && <span className="ms-1">({gw.ip})</span>}
</span>
))}
{/* Центральная часть: информация о сервере */}
<div className="flex-grow-1 py-2 px-3">
<div className="d-flex align-items-center">
{/* Флаг + Название */}
<div className="d-flex align-items-center me-4" style={{ minWidth: '140px' }}>
<span className="me-2" style={{ fontSize: '1.25rem' }}>{countryToFlag(server.country)}</span>
<div>
<div className="fw-semibold" style={{ fontSize: '0.95rem', lineHeight: 1.2 }}>{serverName}</div>
<div className="text-muted" style={{ fontSize: '0.75rem' }}>{server.country}</div>
</div>
</div>
)}
{/* Провайдер */}
<div className="me-4" style={{ minWidth: '100px' }}>
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Провайдер</div>
<div className="fw-medium" style={{ fontSize: '0.85rem' }}>{server.provider}</div>
</div>
{/* DNS */}
<div className="me-4 flex-grow-1" style={{ minWidth: 0 }}>
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Домен</div>
<div style={{ fontSize: '0.85rem', fontFamily: 'ui-monospace, monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{server.dns || server.ip}
</div>
</div>
{/* Туннель + Connections */}
<div className="d-flex align-items-center gap-2 me-3">
<span className="badge bg-secondary-lt text-secondary" style={{ fontSize: '0.7rem' }}>
<IconNetwork size={12} className="me-1" />
{server.tunnel}
</span>
{connectionsCount > 0 && (
<span className="badge bg-green-lt text-green" style={{ fontSize: '0.7rem' }}>
<IconCloud size={12} className="me-1" />
{connectionsCount}
</span>
)}
</div>
{/* Gateway */}
{primaryGateway && (
<div className="me-3" style={{ minWidth: '100px' }}>
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Шлюз</div>
<div className="fw-medium text-success" style={{ fontSize: '0.85rem' }}>{primaryGateway}</div>
</div>
)}
</div>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-1 px-2 border-start" style={{ height: '60px' }}>
<button className="btn btn-ghost-primary btn-icon btn-sm" title="Скопировать" onClick={(e) => { e.stopPropagation(); onQuickCopy?.(server); }}>
<IconCopy size={16} />
</button>
<button className="btn btn-ghost-secondary btn-icon btn-sm" title="Ссылка" onClick={(e) => { e.stopPropagation(); onGenerateLink?.(server); }}>
<IconLink size={16} />
</button>
<button className="btn btn-ghost-secondary btn-icon btn-sm" title="Редактировать" onClick={(e) => { e.stopPropagation(); onEdit?.(server); }}>
<IconEdit size={16} />
</button>
<button className="btn btn-ghost-danger btn-icon btn-sm" title="Удалить" onClick={(e) => { e.stopPropagation(); onDelete?.(server); }}>
<IconTrash size={16} />
</button>
<div className="vr mx-1" style={{ height: '24px' }}></div>
<button className="btn btn-ghost-secondary btn-icon btn-sm" onClick={() => setExpanded(!expanded)} title={expanded ? 'Свернуть' : 'Развернуть'}>
{expanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
</button>
</div>
</div>
)}
{/* Расширенная информация */}
{expanded && (
<div className="border-top bg-light" style={{ borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' }}>
<div className="row g-0">
{/* IP адреса */}
<div className="col-md-6 p-3 border-end">
<div className="text-muted small mb-2 fw-medium">
<IconWorld size={14} className="me-1" />
Сетевые адреса
</div>
<div className="row g-2">
<div className="col-4">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>IP</div>
<code className="small">{server.ip}</code>
</div>
<div className="col-4">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Внешний</div>
<code className="small">{server.extIp || '—'}</code>
</div>
<div className="col-4">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Внутренний</div>
<code className="small">{server.internalIp || '—'}</code>
</div>
</div>
</div>
{/* Шлюзы */}
<div className="col-md-6 p-3">
<div className="text-muted small mb-2 fw-medium">
<IconServer size={14} className="me-1" />
Шлюзы ({server.gateways?.length || 0})
</div>
{server.gateways?.length > 0 ? (
<div className="d-flex flex-wrap gap-2">
{server.gateways.map((gw, idx) => (
<span
key={gw.id || idx}
className={`badge ${gw.primary ? 'bg-blue-lt text-blue' : 'bg-secondary-lt text-secondary'}`}
title={gw.comment || (gw.primary ? 'Основной' : 'Резервный')}
>
{gw.primary && <IconCheck size={10} className="me-1" />}
{gw.name}
{gw.ip && <span className="ms-1 opacity-75">({gw.ip})</span>}
</span>
))}
</div>
) : (
<span className="text-muted small">Нет шлюзов</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}