feat: Refactor ServerManager component to enhance server management features, including improved filtering, sorting, pagination, and bulk actions, while adding new server and connection management functionalities.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
This commit is contained in:
+857
-848
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
IconTrash,
|
||||||
|
IconEdit,
|
||||||
|
IconCopy,
|
||||||
|
IconX,
|
||||||
|
IconNetwork,
|
||||||
|
IconDownload,
|
||||||
|
IconCheck
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export default function ServerBulkActions({
|
||||||
|
selectedCount,
|
||||||
|
onClearSelection,
|
||||||
|
onBulkDelete,
|
||||||
|
onBulkChangeTunnel,
|
||||||
|
onBulkExport,
|
||||||
|
onBulkCopyLinks,
|
||||||
|
tunnelTypes = ['GRE', 'IPSec', 'WireGuard', 'OpenVPN']
|
||||||
|
}) {
|
||||||
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="server-bulk-actions position-sticky bg-dark text-white rounded-3 shadow-lg mx-auto px-3 py-2"
|
||||||
|
style={{
|
||||||
|
bottom: '20px',
|
||||||
|
zIndex: 1050,
|
||||||
|
maxWidth: '700px',
|
||||||
|
animation: 'slideUp 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center justify-content-between gap-3">
|
||||||
|
{/* Счётчик выбранных */}
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span className="badge bg-primary fs-6">{selectedCount}</span>
|
||||||
|
<span className="text-white-50">выбрано</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Действия */}
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{/* Смена типа туннеля */}
|
||||||
|
<div className="dropdown">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-light btn-sm dropdown-toggle d-flex align-items-center"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
>
|
||||||
|
<IconNetwork size={16} className="me-1" />
|
||||||
|
Туннель
|
||||||
|
</button>
|
||||||
|
<div className="dropdown-menu dropdown-menu-dark">
|
||||||
|
<div className="dropdown-header">Сменить тип туннеля</div>
|
||||||
|
{tunnelTypes.map(tunnel => (
|
||||||
|
<button
|
||||||
|
key={tunnel}
|
||||||
|
className="dropdown-item"
|
||||||
|
onClick={() => onBulkChangeTunnel(tunnel)}
|
||||||
|
>
|
||||||
|
{tunnel}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Копировать ссылки */}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-light btn-sm d-flex align-items-center"
|
||||||
|
onClick={onBulkCopyLinks}
|
||||||
|
title="Скопировать ссылки для выбранных серверов"
|
||||||
|
>
|
||||||
|
<IconCopy size={16} className="me-1" />
|
||||||
|
Копировать ссылки
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Экспорт */}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-light btn-sm d-flex align-items-center"
|
||||||
|
onClick={onBulkExport}
|
||||||
|
title="Экспортировать выбранные серверы"
|
||||||
|
>
|
||||||
|
<IconDownload size={16} className="me-1" />
|
||||||
|
Экспорт
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Удалить */}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-danger btn-sm d-flex align-items-center"
|
||||||
|
onClick={onBulkDelete}
|
||||||
|
title="Удалить выбранные серверы"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} className="me-1" />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Отменить выделение */}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-sm btn-icon"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
title="Снять выделение"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import {
|
||||||
|
IconServer,
|
||||||
|
IconWorld,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconLink,
|
||||||
|
IconCopy,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronUp,
|
||||||
|
IconNetwork,
|
||||||
|
IconMapPin,
|
||||||
|
IconCheck,
|
||||||
|
IconSquare,
|
||||||
|
IconSquareCheck
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
// Преобразование кода страны в emoji-флаг
|
||||||
|
function countryToFlag(isoCode) {
|
||||||
|
if (!isoCode) return '';
|
||||||
|
return isoCode
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/./g, char =>
|
||||||
|
String.fromCodePoint(127397 + char.charCodeAt())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerCard({
|
||||||
|
server,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onGenerateLink,
|
||||||
|
onQuickCopy,
|
||||||
|
compact = false
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const isExit = server.type === 'exit';
|
||||||
|
const primaryGateway = server.gateways?.find(g => g.primary)?.name || server.gateway || '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`server-card card mb-2 ${selected ? 'border-primary' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderWidth: selected ? '2px' : '1px',
|
||||||
|
transition: 'all 0.15s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body p-3">
|
||||||
|
{/* Верхняя строка: checkbox + IP + тип + actions */}
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{/* Чекбокс выбора */}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm p-0"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onSelect(server); }}
|
||||||
|
style={{ width: '24px', height: '24px' }}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<IconSquareCheck size={18} className="text-primary" />
|
||||||
|
) : (
|
||||||
|
<IconSquare size={18} className="text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* IP адрес */}
|
||||||
|
<div>
|
||||||
|
<code className="fs-5 fw-bold">{server.ip}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Тип */}
|
||||||
|
<span className={`badge ${isExit ? 'bg-red-lt text-danger' : 'bg-blue-lt text-blue'}`}>
|
||||||
|
{isExit ? 'Exit' : 'Jumphost'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Страна */}
|
||||||
|
<span className="badge bg-secondary-lt">
|
||||||
|
{countryToFlag(server.country)} {server.country}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Действия */}
|
||||||
|
<div className="btn-list gap-1 mb-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-primary 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>
|
||||||
|
|
||||||
|
{/* Основная информация */}
|
||||||
|
<div className="d-flex flex-wrap gap-3 mt-2 text-muted small">
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
<IconWorld size={14} />
|
||||||
|
<span>{server.dns || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
<IconMapPin size={14} />
|
||||||
|
<span>{server.provider}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
<IconNetwork size={14} />
|
||||||
|
<span className="badge bg-green-lt text-green small">{server.tunnel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
<IconServer size={14} />
|
||||||
|
<span>{primaryGateway}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Расширенная информация */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-3 pt-3 border-top">
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="text-muted small mb-1">Внешний IP</div>
|
||||||
|
<code>{server.extIp || '—'}</code>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="text-muted small mb-1">Внутренний IP</div>
|
||||||
|
<code>{server.internalIp || '—'}</code>
|
||||||
|
</div>
|
||||||
|
{server.gateways?.length > 0 && (
|
||||||
|
<div className="col-12 mt-2">
|
||||||
|
<div className="text-muted small mb-1">Все шлюзы</div>
|
||||||
|
<div className="d-flex flex-wrap gap-1">
|
||||||
|
{server.gateways.map((gw, idx) => (
|
||||||
|
<span
|
||||||
|
key={gw.id || idx}
|
||||||
|
className={`badge ${gw.primary ? 'bg-primary' : 'bg-secondary-lt'}`}
|
||||||
|
title={gw.comment || (gw.primary ? 'Основной шлюз' : 'Резервный шлюз')}
|
||||||
|
>
|
||||||
|
{gw.name}{gw.ip ? ` (${gw.ip})` : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
IconServer,
|
||||||
|
IconWorld,
|
||||||
|
IconBuildingSkyscraper,
|
||||||
|
IconNetwork,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronRight,
|
||||||
|
IconCheck,
|
||||||
|
IconX,
|
||||||
|
IconSearch,
|
||||||
|
IconFilter,
|
||||||
|
IconRefresh
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
// Преобразование кода страны в emoji-флаг
|
||||||
|
function countryToFlag(isoCode) {
|
||||||
|
if (!isoCode) return '';
|
||||||
|
return isoCode
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/./g, char =>
|
||||||
|
String.fromCodePoint(127397 + char.charCodeAt())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerSidebar({
|
||||||
|
servers = [],
|
||||||
|
filters = {},
|
||||||
|
onFiltersChange,
|
||||||
|
onSearch,
|
||||||
|
searchTerm = '',
|
||||||
|
onReset
|
||||||
|
}) {
|
||||||
|
const [expandedSections, setExpandedSections] = useState({
|
||||||
|
country: true,
|
||||||
|
provider: true,
|
||||||
|
type: true,
|
||||||
|
tunnel: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подсчёт серверов по категориям
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const byCountry = {};
|
||||||
|
const byProvider = {};
|
||||||
|
const byType = {};
|
||||||
|
const byTunnel = {};
|
||||||
|
|
||||||
|
servers.forEach(srv => {
|
||||||
|
byCountry[srv.country] = (byCountry[srv.country] || 0) + 1;
|
||||||
|
byProvider[srv.provider] = (byProvider[srv.provider] || 0) + 1;
|
||||||
|
byType[srv.type] = (byType[srv.type] || 0) + 1;
|
||||||
|
byTunnel[srv.tunnel] = (byTunnel[srv.tunnel] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { byCountry, byProvider, byType, byTunnel };
|
||||||
|
}, [servers]);
|
||||||
|
|
||||||
|
const toggleSection = (section) => {
|
||||||
|
setExpandedSections(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: !prev[section]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterClick = (category, value) => {
|
||||||
|
const currentValue = filters[category];
|
||||||
|
const newValue = currentValue === value ? '' : value;
|
||||||
|
onFiltersChange({ ...filters, [category]: newValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFiltersCount = Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0);
|
||||||
|
|
||||||
|
const typeLabels = {
|
||||||
|
'jumphost': 'Jumphost',
|
||||||
|
'exit': 'Выходная нода'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="server-sidebar" style={{
|
||||||
|
width: '280px',
|
||||||
|
minWidth: '280px',
|
||||||
|
borderRight: '1px solid var(--tblr-border-color)',
|
||||||
|
height: 'calc(100vh - 180px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'var(--tblr-bg-surface)'
|
||||||
|
}}>
|
||||||
|
{/* Заголовок с кнопкой сброса */}
|
||||||
|
<div className="d-flex align-items-center justify-content-between p-3 border-bottom">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconFilter size={18} className="me-2 text-muted" />
|
||||||
|
<span className="fw-semibold">Фильтры</span>
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<span className="badge bg-primary ms-2">{activeFiltersCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-sm btn-icon"
|
||||||
|
onClick={onReset}
|
||||||
|
title="Сбросить все фильтры"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="p-3 border-bottom">
|
||||||
|
<div className="input-icon">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconSearch size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholder="Поиск серверов..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<span
|
||||||
|
className="input-icon-addon cursor-pointer"
|
||||||
|
onClick={() => onSearch('')}
|
||||||
|
style={{ right: 0 }}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция: Страны */}
|
||||||
|
<div className="filter-section">
|
||||||
|
<div
|
||||||
|
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||||
|
onClick={() => toggleSection('country')}
|
||||||
|
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconWorld size={16} className="me-2 text-azure" />
|
||||||
|
<span className="fw-medium small">Страны</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.country ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||||
|
</div>
|
||||||
|
{expandedSections.country && (
|
||||||
|
<div className="p-2">
|
||||||
|
{Object.entries(stats.byCountry)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([country, count]) => (
|
||||||
|
<button
|
||||||
|
key={country}
|
||||||
|
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||||
|
filters.country === country ? 'btn-primary' : 'btn-ghost-secondary'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFilterClick('country', country)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="me-2">{countryToFlag(country)}</span>
|
||||||
|
{country}
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${filters.country === country ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция: Провайдеры */}
|
||||||
|
<div className="filter-section">
|
||||||
|
<div
|
||||||
|
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||||
|
onClick={() => toggleSection('provider')}
|
||||||
|
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconBuildingSkyscraper size={16} className="me-2 text-purple" />
|
||||||
|
<span className="fw-medium small">Провайдеры</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.provider ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||||
|
</div>
|
||||||
|
{expandedSections.provider && (
|
||||||
|
<div className="p-2">
|
||||||
|
{Object.entries(stats.byProvider)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([provider, count]) => (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||||
|
filters.provider === provider ? 'btn-primary' : 'btn-ghost-secondary'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFilterClick('provider', provider)}
|
||||||
|
>
|
||||||
|
<span>{provider}</span>
|
||||||
|
<span className={`badge ${filters.provider === provider ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция: Типы серверов */}
|
||||||
|
<div className="filter-section">
|
||||||
|
<div
|
||||||
|
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||||
|
onClick={() => toggleSection('type')}
|
||||||
|
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconServer size={16} className="me-2 text-green" />
|
||||||
|
<span className="fw-medium small">Тип сервера</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.type ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||||
|
</div>
|
||||||
|
{expandedSections.type && (
|
||||||
|
<div className="p-2">
|
||||||
|
{Object.entries(stats.byType)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([type, count]) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||||
|
filters.type === type ? 'btn-primary' : 'btn-ghost-secondary'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFilterClick('type', type)}
|
||||||
|
>
|
||||||
|
<span className="d-flex align-items-center">
|
||||||
|
<span className={`status-dot me-2 ${type === 'exit' ? 'bg-danger' : 'bg-azure'}`}></span>
|
||||||
|
{typeLabels[type] || type}
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${filters.type === type ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция: Туннели */}
|
||||||
|
<div className="filter-section">
|
||||||
|
<div
|
||||||
|
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||||
|
onClick={() => toggleSection('tunnel')}
|
||||||
|
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconNetwork size={16} className="me-2 text-orange" />
|
||||||
|
<span className="fw-medium small">Тип туннеля</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.tunnel ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||||
|
</div>
|
||||||
|
{expandedSections.tunnel && (
|
||||||
|
<div className="p-2">
|
||||||
|
{Object.entries(stats.byTunnel)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([tunnel, count]) => (
|
||||||
|
<button
|
||||||
|
key={tunnel}
|
||||||
|
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||||
|
filters.tunnel === tunnel ? 'btn-primary' : 'btn-ghost-secondary'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFilterClick('tunnel', tunnel)}
|
||||||
|
>
|
||||||
|
<span>{tunnel}</span>
|
||||||
|
<span className={`badge ${filters.tunnel === tunnel ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
<div className="p-3 border-top mt-auto">
|
||||||
|
<div className="text-muted small">
|
||||||
|
<div className="d-flex justify-content-between mb-1">
|
||||||
|
<span>Всего серверов:</span>
|
||||||
|
<span className="fw-bold text-body">{servers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span>Выходных нод:</span>
|
||||||
|
<span className="fw-bold text-danger">{stats.byType['exit'] || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span>Jumphosts:</span>
|
||||||
|
<span className="fw-bold text-azure">{stats.byType['jumphost'] || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,4 +2,7 @@ export { default as EditServerModal } from './EditServerModal.jsx';
|
|||||||
export { default as DeleteServerModal } from './DeleteServerModal.jsx';
|
export { default as DeleteServerModal } from './DeleteServerModal.jsx';
|
||||||
export { default as LinkGeneratorModal } from './LinkGeneratorModal.jsx';
|
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 ServerCard } from './ServerCard.jsx';
|
||||||
|
export { default as ServerBulkActions } from './ServerBulkActions.jsx';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user