refactor(ServerSidebar): implement new filter section and sidebar styling for improved user interaction and organization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
This commit is contained in:
@@ -256,6 +256,42 @@
|
|||||||
/* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler
|
/* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler
|
||||||
(например, badge bg-blue-lt text-blue) */
|
(например, badge bg-blue-lt text-blue) */
|
||||||
|
|
||||||
|
/* --- Server sidebar (фильтры на /servers) — Tabler card + list-group --- */
|
||||||
|
.server-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
max-height: calc(100vh - 180px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.server-sidebar .card-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.server-sidebar .card-body.border-bottom,
|
||||||
|
.server-sidebar .card-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.server-sidebar-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
.server-sidebar .filter-section .list-group-header {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
color: var(--tblr-body-color);
|
||||||
|
}
|
||||||
|
.server-sidebar .filter-section .list-group-header:hover {
|
||||||
|
background-color: var(--tblr-bg-surface-tertiary);
|
||||||
|
}
|
||||||
|
.server-sidebar .list-group-item {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding-top: 0.375rem;
|
||||||
|
padding-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive navbar */
|
/* Responsive navbar */
|
||||||
@media (max-width: 991.98px) {
|
@media (max-width: 991.98px) {
|
||||||
.navbar-collapse {
|
.navbar-collapse {
|
||||||
|
|||||||
@@ -6,29 +6,63 @@ import {
|
|||||||
IconNetwork,
|
IconNetwork,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconX,
|
|
||||||
IconSearch,
|
|
||||||
IconFilter,
|
IconFilter,
|
||||||
IconDatabase
|
IconSearch,
|
||||||
|
IconDatabase,
|
||||||
|
IconX
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
// Преобразование кода страны в emoji-флаг
|
|
||||||
function countryToFlag(isoCode) {
|
function countryToFlag(isoCode) {
|
||||||
if (!isoCode) return '';
|
if (!isoCode) return '';
|
||||||
const codeMap = {
|
const codeMap = {
|
||||||
'SWE': 'SE',
|
SWE: 'SE',
|
||||||
'RUS': 'RU',
|
RUS: 'RU',
|
||||||
'USA': 'US',
|
USA: 'US',
|
||||||
'GER': 'DE',
|
GER: 'DE',
|
||||||
'FIN': 'FI',
|
FIN: 'FI',
|
||||||
'NLD': 'NL'
|
NLD: 'NL'
|
||||||
};
|
};
|
||||||
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
|
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
|
||||||
if (!code || code.length !== 2) return isoCode;
|
if (!code || code.length !== 2) return isoCode;
|
||||||
return code
|
return code.replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()));
|
||||||
.replace(/./g, char =>
|
}
|
||||||
String.fromCodePoint(127397 + char.charCodeAt())
|
|
||||||
);
|
function FilterSection({ title, icon: Icon, iconColor, expanded, onToggle, children }) {
|
||||||
|
return (
|
||||||
|
<div className="filter-section border-bottom">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="list-group-header d-flex align-items-center justify-content-between w-100 border-0 bg-transparent py-2 px-3 text-start"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<span className="d-flex align-items-center">
|
||||||
|
<Icon size={16} className={`me-2 ${iconColor || 'text-muted'}`} />
|
||||||
|
<span>{title}</span>
|
||||||
|
</span>
|
||||||
|
{expanded ? <IconChevronDown size={16} className="text-muted" /> : <IconChevronRight size={16} className="text-muted" />}
|
||||||
|
</button>
|
||||||
|
{expanded && <div className="list-group list-group-flush list-group-transparent">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterItem({ label, count, active, onClick, leftAddon }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`list-group-item list-group-item-action d-flex align-items-center justify-content-between py-2 px-3 text-start ${active ? 'active' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="d-flex align-items-center text-truncate">
|
||||||
|
{leftAddon}
|
||||||
|
<span className="text-truncate">{label}</span>
|
||||||
|
</span>
|
||||||
|
<span className={`badge ms-2 flex-shrink-0 ${active ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ServerSidebar({
|
export default function ServerSidebar({
|
||||||
@@ -46,378 +80,185 @@ export default function ServerSidebar({
|
|||||||
tunnel: false
|
tunnel: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Подсчёт серверов по категориям
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const byCountry = {};
|
const byCountry = {};
|
||||||
const byProvider = {};
|
const byProvider = {};
|
||||||
const byType = {};
|
const byType = {};
|
||||||
const byTunnel = {};
|
const byTunnel = {};
|
||||||
|
|
||||||
servers.forEach(srv => {
|
servers.forEach(srv => {
|
||||||
byCountry[srv.country] = (byCountry[srv.country] || 0) + 1;
|
byCountry[srv.country] = (byCountry[srv.country] || 0) + 1;
|
||||||
byProvider[srv.provider] = (byProvider[srv.provider] || 0) + 1;
|
byProvider[srv.provider] = (byProvider[srv.provider] || 0) + 1;
|
||||||
byType[srv.type] = (byType[srv.type] || 0) + 1;
|
byType[srv.type] = (byType[srv.type] || 0) + 1;
|
||||||
byTunnel[srv.tunnel] = (byTunnel[srv.tunnel] || 0) + 1;
|
byTunnel[srv.tunnel] = (byTunnel[srv.tunnel] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { byCountry, byProvider, byType, byTunnel };
|
return { byCountry, byProvider, byType, byTunnel };
|
||||||
}, [servers]);
|
}, [servers]);
|
||||||
|
|
||||||
const toggleSection = (section) => {
|
const toggleSection = section => {
|
||||||
setExpandedSections(prev => ({
|
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||||
...prev,
|
|
||||||
[section]: !prev[section]
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterClick = (category, value) => {
|
const handleFilterClick = (category, value) => {
|
||||||
const currentValue = filters[category];
|
const current = filters[category];
|
||||||
const newValue = currentValue === value ? '' : value;
|
onFiltersChange({ ...filters, [category]: current === value ? '' : value });
|
||||||
onFiltersChange({ ...filters, [category]: newValue });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeFiltersCount = Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0);
|
const activeFiltersCount = Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0);
|
||||||
|
const typeLabels = { jumphost: 'Jumphost', exit: 'Выходная нода' };
|
||||||
const typeLabels = {
|
|
||||||
'jumphost': 'Jumphost',
|
|
||||||
'exit': 'Выходная нода'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="server-sidebar card flex-shrink-0 mb-0">
|
||||||
className="server-sidebar d-flex flex-column"
|
<div className="card-header py-3 d-flex align-items-center justify-content-between">
|
||||||
style={{
|
|
||||||
width: '260px',
|
|
||||||
minWidth: '260px',
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: '12px',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
|
||||||
height: 'fit-content',
|
|
||||||
maxHeight: 'calc(100vh - 200px)',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Заголовок */}
|
|
||||||
<div
|
|
||||||
className="d-flex align-items-center justify-content-between px-3 py-3"
|
|
||||||
style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<IconFilter size={18} className="text-primary me-2" />
|
<IconFilter size={18} className="text-primary me-2" />
|
||||||
<span className="fw-semibold">Фильтры</span>
|
<span className="fw-semibold">Фильтры</span>
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<span
|
<span className="badge bg-primary-lt text-primary ms-2">{activeFiltersCount}</span>
|
||||||
className="badge bg-primary ms-2"
|
|
||||||
style={{ fontSize: '0.7rem', padding: '3px 6px' }}
|
|
||||||
>
|
|
||||||
{activeFiltersCount}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
title="Сбросить"
|
title="Сбросить фильтры"
|
||||||
style={{ width: 28, height: 28 }}
|
aria-label="Сбросить фильтры"
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Поиск */}
|
<div className="card-body py-2 border-bottom">
|
||||||
<div className="px-3 py-2" style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
|
|
||||||
<div className="input-icon">
|
<div className="input-icon">
|
||||||
<span className="input-icon-addon">
|
<span className="input-icon-addon">
|
||||||
<IconSearch size={14} className="text-muted" />
|
<IconSearch size={16} className="text-muted" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control form-control-sm"
|
className="form-control form-control-sm"
|
||||||
placeholder="Поиск..."
|
placeholder="Поиск..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
onChange={e => onSearch(e.target.value)}
|
||||||
style={{
|
aria-label="Поиск серверов"
|
||||||
background: 'rgba(0,0,0,0.02)',
|
|
||||||
border: '1px solid rgba(0,0,0,0.06)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '0.85rem'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Скроллируемый контент */}
|
<div className="server-sidebar-body overflow-auto">
|
||||||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
<FilterSection
|
||||||
{/* Секция: Страны */}
|
title="Страны"
|
||||||
<div className="filter-section">
|
icon={IconWorld}
|
||||||
<div
|
iconColor="text-blue"
|
||||||
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
expanded={expandedSections.country}
|
||||||
onClick={() => toggleSection('country')}
|
onToggle={() => toggleSection('country')}
|
||||||
style={{
|
>
|
||||||
background: 'rgba(0,0,0,0.02)',
|
{Object.entries(stats.byCountry)
|
||||||
borderBottom: '1px solid rgba(0,0,0,0.04)'
|
.sort((a, b) => b[1] - a[1])
|
||||||
}}
|
.map(([country, count]) => (
|
||||||
>
|
<FilterItem
|
||||||
<div className="d-flex align-items-center">
|
key={country}
|
||||||
<IconWorld size={15} className="me-2" style={{ color: '#3b82f6' }} />
|
label={country}
|
||||||
<span className="fw-medium small">Страны</span>
|
count={count}
|
||||||
</div>
|
active={filters.country === country}
|
||||||
{expandedSections.country ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
onClick={() => handleFilterClick('country', country)}
|
||||||
</div>
|
leftAddon={
|
||||||
{expandedSections.country && (
|
<span className="me-2" style={{ fontSize: '1rem' }} aria-hidden>
|
||||||
<div className="p-2">
|
{countryToFlag(country)}
|
||||||
{Object.entries(stats.byCountry)
|
</span>
|
||||||
.sort((a, b) => b[1] - a[1])
|
}
|
||||||
.map(([country, count]) => (
|
/>
|
||||||
<button
|
))}
|
||||||
key={country}
|
</FilterSection>
|
||||||
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
|
||||||
filters.country === country ? 'btn-primary' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => handleFilterClick('country', country)}
|
|
||||||
style={{
|
|
||||||
background: filters.country === country ? undefined : 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '8px 10px',
|
|
||||||
transition: 'background 0.15s'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (filters.country !== country) {
|
|
||||||
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (filters.country !== country) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="d-flex align-items-center">
|
|
||||||
<span className="me-2" style={{ fontSize: '1.1rem' }}>{countryToFlag(country)}</span>
|
|
||||||
<span className={filters.country === country ? 'text-white' : ''}>{country}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`badge ${filters.country === country ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
|
|
||||||
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Секция: Провайдеры */}
|
<FilterSection
|
||||||
<div className="filter-section">
|
title="Провайдеры"
|
||||||
<div
|
icon={IconBuildingSkyscraper}
|
||||||
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
iconColor="text-purple"
|
||||||
onClick={() => toggleSection('provider')}
|
expanded={expandedSections.provider}
|
||||||
style={{
|
onToggle={() => toggleSection('provider')}
|
||||||
background: 'rgba(0,0,0,0.02)',
|
>
|
||||||
borderBottom: '1px solid rgba(0,0,0,0.04)'
|
{Object.entries(stats.byProvider)
|
||||||
}}
|
.sort((a, b) => b[1] - a[1])
|
||||||
>
|
.map(([provider, count]) => (
|
||||||
<div className="d-flex align-items-center">
|
<FilterItem
|
||||||
<IconBuildingSkyscraper size={15} className="me-2" style={{ color: '#8b5cf6' }} />
|
key={provider}
|
||||||
<span className="fw-medium small">Провайдеры</span>
|
label={provider}
|
||||||
</div>
|
count={count}
|
||||||
{expandedSections.provider ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
active={filters.provider === provider}
|
||||||
</div>
|
onClick={() => handleFilterClick('provider', provider)}
|
||||||
{expandedSections.provider && (
|
/>
|
||||||
<div className="p-2">
|
))}
|
||||||
{Object.entries(stats.byProvider)
|
</FilterSection>
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([provider, count]) => (
|
|
||||||
<button
|
|
||||||
key={provider}
|
|
||||||
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
|
||||||
filters.provider === provider ? 'btn-primary' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => handleFilterClick('provider', provider)}
|
|
||||||
style={{
|
|
||||||
background: filters.provider === provider ? undefined : 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '8px 10px'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (filters.provider !== provider) {
|
|
||||||
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (filters.provider !== provider) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={filters.provider === provider ? 'text-white' : ''}>{provider}</span>
|
|
||||||
<span
|
|
||||||
className={`badge ${filters.provider === provider ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
|
|
||||||
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Секция: Типы */}
|
<FilterSection
|
||||||
<div className="filter-section">
|
title="Тип сервера"
|
||||||
<div
|
icon={IconServer}
|
||||||
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
iconColor="text-green"
|
||||||
onClick={() => toggleSection('type')}
|
expanded={expandedSections.type}
|
||||||
style={{
|
onToggle={() => toggleSection('type')}
|
||||||
background: 'rgba(0,0,0,0.02)',
|
>
|
||||||
borderBottom: '1px solid rgba(0,0,0,0.04)'
|
{Object.entries(stats.byType)
|
||||||
}}
|
.sort((a, b) => b[1] - a[1])
|
||||||
>
|
.map(([type, count]) => (
|
||||||
<div className="d-flex align-items-center">
|
<FilterItem
|
||||||
<IconServer size={15} className="me-2" style={{ color: '#10b981' }} />
|
key={type}
|
||||||
<span className="fw-medium small">Тип сервера</span>
|
label={typeLabels[type] || type}
|
||||||
</div>
|
count={count}
|
||||||
{expandedSections.type ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
active={filters.type === type}
|
||||||
</div>
|
onClick={() => handleFilterClick('type', type)}
|
||||||
{expandedSections.type && (
|
leftAddon={
|
||||||
<div className="p-2">
|
<span
|
||||||
{Object.entries(stats.byType)
|
className="rounded-circle me-2 flex-shrink-0"
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([type, count]) => (
|
|
||||||
<button
|
|
||||||
key={type}
|
|
||||||
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
|
||||||
filters.type === type ? 'btn-primary' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => handleFilterClick('type', type)}
|
|
||||||
style={{
|
style={{
|
||||||
background: filters.type === type ? undefined : 'transparent',
|
width: 8,
|
||||||
border: 'none',
|
height: 8,
|
||||||
borderRadius: '8px',
|
background: type === 'exit' ? 'var(--tblr-red)' : 'var(--tblr-primary)'
|
||||||
padding: '8px 10px'
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
aria-hidden
|
||||||
if (filters.type !== type) {
|
/>
|
||||||
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
|
}
|
||||||
}
|
/>
|
||||||
}}
|
))}
|
||||||
onMouseLeave={(e) => {
|
</FilterSection>
|
||||||
if (filters.type !== type) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="d-flex align-items-center">
|
|
||||||
<span
|
|
||||||
className="rounded-circle me-2"
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
background: type === 'exit' ? '#ef4444' : '#3b82f6'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className={filters.type === type ? 'text-white' : ''}>
|
|
||||||
{typeLabels[type] || type}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`badge ${filters.type === type ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
|
|
||||||
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Секция: Туннели */}
|
<FilterSection
|
||||||
<div className="filter-section">
|
title="Туннель"
|
||||||
<div
|
icon={IconNetwork}
|
||||||
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
iconColor="text-orange"
|
||||||
onClick={() => toggleSection('tunnel')}
|
expanded={expandedSections.tunnel}
|
||||||
style={{
|
onToggle={() => toggleSection('tunnel')}
|
||||||
background: 'rgba(0,0,0,0.02)',
|
>
|
||||||
borderBottom: '1px solid rgba(0,0,0,0.04)'
|
{Object.entries(stats.byTunnel)
|
||||||
}}
|
.sort((a, b) => b[1] - a[1])
|
||||||
>
|
.map(([tunnel, count]) => (
|
||||||
<div className="d-flex align-items-center">
|
<FilterItem
|
||||||
<IconNetwork size={15} className="me-2" style={{ color: '#f59e0b' }} />
|
key={tunnel}
|
||||||
<span className="fw-medium small">Туннель</span>
|
label={tunnel}
|
||||||
</div>
|
count={count}
|
||||||
{expandedSections.tunnel ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
active={filters.tunnel === tunnel}
|
||||||
</div>
|
onClick={() => handleFilterClick('tunnel', tunnel)}
|
||||||
{expandedSections.tunnel && (
|
/>
|
||||||
<div className="p-2">
|
))}
|
||||||
{Object.entries(stats.byTunnel)
|
</FilterSection>
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([tunnel, count]) => (
|
|
||||||
<button
|
|
||||||
key={tunnel}
|
|
||||||
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
|
||||||
filters.tunnel === tunnel ? 'btn-primary' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => handleFilterClick('tunnel', tunnel)}
|
|
||||||
style={{
|
|
||||||
background: filters.tunnel === tunnel ? undefined : 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '8px 10px'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (filters.tunnel !== tunnel) {
|
|
||||||
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (filters.tunnel !== tunnel) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={filters.tunnel === tunnel ? 'text-white' : ''}>{tunnel}</span>
|
|
||||||
<span
|
|
||||||
className={`badge ${filters.tunnel === tunnel ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
|
|
||||||
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Статистика внизу */}
|
<div className="card-footer py-3 bg-secondary-lt">
|
||||||
<div
|
|
||||||
className="px-3 py-3"
|
|
||||||
style={{
|
|
||||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
|
||||||
background: 'rgba(0,0,0,0.01)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center mb-2">
|
<div className="d-flex align-items-center mb-2">
|
||||||
<IconDatabase size={14} className="text-muted me-2" />
|
<IconDatabase size={16} className="text-muted me-2" />
|
||||||
<span className="text-muted small">Статистика</span>
|
<span className="text-muted small fw-medium">Статистика</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex justify-content-between mb-1">
|
<div className="d-flex justify-content-between small mb-1">
|
||||||
<span className="small text-muted">Всего серверов</span>
|
<span className="text-muted">Всего серверов</span>
|
||||||
<span className="small fw-bold">{servers.length}</span>
|
<span className="fw-semibold">{servers.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex justify-content-between mb-1">
|
<div className="d-flex justify-content-between small mb-1">
|
||||||
<span className="small text-muted">Выходных нод</span>
|
<span className="text-muted">Выходных нод</span>
|
||||||
<span className="small fw-bold" style={{ color: '#ef4444' }}>{stats.byType['exit'] || 0}</span>
|
<span className="fw-semibold text-danger">{stats.byType['exit'] || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex justify-content-between">
|
<div className="d-flex justify-content-between small">
|
||||||
<span className="small text-muted">Jumphosts</span>
|
<span className="text-muted">Jumphosts</span>
|
||||||
<span className="small fw-bold" style={{ color: '#3b82f6' }}>{stats.byType['jumphost'] || 0}</span>
|
<span className="fw-semibold text-primary">{stats.byType['jumphost'] || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user