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

This commit is contained in:
2026-01-25 03:46:35 +07:00
parent 0310147044
commit 6f1d307ee0
+145 -6
View File
@@ -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>
) : (