feat(ServerCard): улучшена компоновка карточки сервера - визуальные группы
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user