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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user