feat(NetworkConfigManager): implement category-based accordion view for gateways and interfaces, improving organization and user navigation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m51s
This commit is contained in:
@@ -29,6 +29,8 @@ import {
|
||||
IconCode,
|
||||
IconArrowsRightLeft,
|
||||
IconLock,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
@@ -118,6 +120,7 @@ function NetworkConfigManager() {
|
||||
const [gatewayTypeFilter, setGatewayTypeFilter] = useState(''); // Для фильтрации gateway по типу
|
||||
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
|
||||
const [ipRegistrySearch, setIpRegistrySearch] = useState(''); // Поиск в реестре IP
|
||||
const [expandedCategories, setExpandedCategories] = useState(new Set(['home', 'jumphost', 'exit'])); // Развернутые категории аккордеонов
|
||||
|
||||
// === Modals ===
|
||||
const [gatewayModalOpen, setGatewayModalOpen] = useState(false);
|
||||
@@ -247,6 +250,45 @@ function NetworkConfigManager() {
|
||||
return server.dns || server.ip;
|
||||
};
|
||||
|
||||
// === Определение категории сервера ===
|
||||
const getServerCategory = (serverId) => {
|
||||
if (!serverId || serverId === '__unassigned__') return 'other';
|
||||
const server = getServerInfo(serverId);
|
||||
if (!server) return 'other';
|
||||
const serverType = server.type?.toLowerCase();
|
||||
if (serverType === 'home') return 'home';
|
||||
if (serverType === 'jumphost') return 'jumphost';
|
||||
if (serverType === 'exit') return 'exit';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
// === Получение названия категории ===
|
||||
const getCategoryLabel = (category) => {
|
||||
switch (category) {
|
||||
case 'home':
|
||||
return 'Домашние (входные роутеры)';
|
||||
case 'jumphost':
|
||||
return 'Jumphosts';
|
||||
case 'exit':
|
||||
return 'Exit nodes';
|
||||
default:
|
||||
return 'Прочие';
|
||||
}
|
||||
};
|
||||
|
||||
// === Переключение аккордеона категории ===
|
||||
const toggleCategory = (category) => {
|
||||
setExpandedCategories(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// === Уникальные серверы для фильтра ===
|
||||
const uniqueServersInConfig = useMemo(() => {
|
||||
const allServerIds = new Set([
|
||||
@@ -376,6 +418,45 @@ function NetworkConfigManager() {
|
||||
return grouped;
|
||||
}, [filteredInterfaces]);
|
||||
|
||||
// === Группировка по категориям серверов ===
|
||||
const gatewaysByCategory = useMemo(() => {
|
||||
const grouped = {
|
||||
home: {},
|
||||
jumphost: {},
|
||||
exit: {},
|
||||
other: {}
|
||||
};
|
||||
|
||||
Object.entries(gatewaysByServer).forEach(([serverId, gateways]) => {
|
||||
const category = getServerCategory(serverId);
|
||||
if (!grouped[category][serverId]) {
|
||||
grouped[category][serverId] = [];
|
||||
}
|
||||
grouped[category][serverId] = gateways;
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [gatewaysByServer, servers]);
|
||||
|
||||
const interfacesByCategory = useMemo(() => {
|
||||
const grouped = {
|
||||
home: {},
|
||||
jumphost: {},
|
||||
exit: {},
|
||||
other: {}
|
||||
};
|
||||
|
||||
Object.entries(interfacesByServer).forEach(([serverId, interfaces]) => {
|
||||
const category = getServerCategory(serverId);
|
||||
if (!grouped[category][serverId]) {
|
||||
grouped[category][serverId] = [];
|
||||
}
|
||||
grouped[category][serverId] = interfaces;
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [interfacesByServer, servers]);
|
||||
|
||||
// === Сброс фильтров ===
|
||||
const resetFilters = () => {
|
||||
setSearchTerm('');
|
||||
@@ -2041,6 +2122,64 @@ function NetworkConfigManager() {
|
||||
return t?.color || 'secondary';
|
||||
};
|
||||
|
||||
// === Рендер категории с аккордеоном ===
|
||||
const renderCategoryAccordion = (category, itemsByServer, type) => {
|
||||
const categoryItems = Object.entries(itemsByServer);
|
||||
if (categoryItems.length === 0) return null;
|
||||
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
const totalItems = categoryItems.reduce((sum, [, items]) => sum + items.length, 0);
|
||||
|
||||
return (
|
||||
<div key={category} className="card mb-3">
|
||||
{/* Заголовок категории (кликабельный) */}
|
||||
<div
|
||||
className="card-header py-2"
|
||||
style={{
|
||||
backgroundColor: 'rgba(32, 107, 196, 0.05)',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{/* Иконка сворачивания */}
|
||||
{isExpanded ? (
|
||||
<IconChevronUp size={20} className="me-2 text-muted" />
|
||||
) : (
|
||||
<IconChevronDown size={20} className="me-2 text-muted" />
|
||||
)}
|
||||
|
||||
<h3 className="card-title mb-0 me-2">{getCategoryLabel(category)}</h3>
|
||||
<span className="badge bg-blue-lt text-blue ms-auto">
|
||||
{categoryItems.length} {categoryItems.length === 1 ? 'сервер' : categoryItems.length < 5 ? 'сервера' : 'серверов'} • {totalItems} {type === 'gateway' ? (totalItems === 1 ? 'gateway' : 'gateways') : (totalItems === 1 ? 'интерфейс' : 'интерфейсов')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент (сворачиваемый) */}
|
||||
{isExpanded && (
|
||||
<div className="card-body p-3">
|
||||
{categoryItems
|
||||
.sort(([a], [b]) => {
|
||||
// Сортируем: сначала серверы с информацией, потом без привязки
|
||||
if (a === '__unassigned__') return 1;
|
||||
if (b === '__unassigned__') return -1;
|
||||
const serverA = getServerInfo(a);
|
||||
const serverB = getServerInfo(b);
|
||||
const nameA = serverA?.dns || serverA?.ip || a;
|
||||
const nameB = serverB?.dns || serverB?.ip || b;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(([serverId, items]) =>
|
||||
renderServerGroup(serverId, items, type)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// === Рендер карточки сервера с его элементами ===
|
||||
const renderServerGroup = (serverId, items, type) => {
|
||||
const server = getServerInfo(serverId);
|
||||
@@ -2768,10 +2907,10 @@ function NetworkConfigManager() {
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'cards' ? (
|
||||
// Grouped cards view
|
||||
// Grouped cards view with category accordions
|
||||
<div>
|
||||
{Object.entries(gatewaysByServer).map(([serverId, gateways]) =>
|
||||
renderServerGroup(serverId, gateways, 'gateway')
|
||||
{['home', 'jumphost', 'exit', 'other'].map(category =>
|
||||
renderCategoryAccordion(category, gatewaysByCategory[category], 'gateway')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -2944,10 +3083,10 @@ function NetworkConfigManager() {
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'cards' ? (
|
||||
// Grouped cards view
|
||||
// Grouped cards view with category accordions
|
||||
<div>
|
||||
{Object.entries(interfacesByServer).map(([serverId, interfaces]) =>
|
||||
renderServerGroup(serverId, interfaces, 'interface')
|
||||
{['home', 'jumphost', 'exit', 'other'].map(category =>
|
||||
renderCategoryAccordion(category, interfacesByCategory[category], 'interface')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user