feat(NetworkConfigManager): add IP Pool management features; implement filtering and rendering for IP Pools, including server grouping and enhanced UI elements
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s

This commit is contained in:
2026-01-22 18:33:40 +07:00
parent b94b7c67e2
commit 8f5cd700c3
+158 -71
View File
@@ -215,13 +215,14 @@ function NetworkConfigManager() {
const allServerIds = new Set([ const allServerIds = new Set([
...(config.gateways || []).map(g => g.serverId).filter(Boolean), ...(config.gateways || []).map(g => g.serverId).filter(Boolean),
...(config.tunnelInterfaces || []).map(i => i.serverId).filter(Boolean), ...(config.tunnelInterfaces || []).map(i => i.serverId).filter(Boolean),
...(config.ipPools || []).map(p => p.serverId).filter(Boolean),
]); ]);
return Array.from(allServerIds).map(id => ({ return Array.from(allServerIds).map(id => ({
id, id,
label: getServerLabel(id), label: getServerLabel(id),
server: getServerInfo(id), server: getServerInfo(id),
})); }));
}, [config.gateways, config.tunnelInterfaces, servers]); }, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]);
// === Фильтрация === // === Фильтрация ===
const filteredGateways = useMemo(() => { const filteredGateways = useMemo(() => {
@@ -289,17 +290,25 @@ function NetworkConfigManager() {
const filteredPools = useMemo(() => { const filteredPools = useMemo(() => {
let result = [...(config.ipPools || [])]; let result = [...(config.ipPools || [])];
if (serverFilter) {
result = result.filter(p => p.serverId === serverFilter);
}
if (searchTerm) { if (searchTerm) {
const term = searchTerm.toLowerCase(); const term = searchTerm.toLowerCase();
result = result.filter(p => result = result.filter(p => {
p.name?.toLowerCase().includes(term) || const server = getServerInfo(p.serverId);
p.cidr?.toLowerCase().includes(term) || return (
p.description?.toLowerCase().includes(term) p.name?.toLowerCase().includes(term) ||
); p.cidr?.toLowerCase().includes(term) ||
p.description?.toLowerCase().includes(term) ||
getServerLabel(p.serverId).toLowerCase().includes(term)
);
});
} }
return result; return result;
}, [config.ipPools, searchTerm]); }, [config.ipPools, serverFilter, searchTerm, servers]);
// === Группировка по серверам === // === Группировка по серверам ===
const gatewaysByServer = useMemo(() => { const gatewaysByServer = useMemo(() => {
@@ -1170,7 +1179,9 @@ function NetworkConfigManager() {
<div className="row row-cards"> <div className="row row-cards">
{items.map(item => ( {items.map(item => (
<div key={item.id} className="col-12 col-md-6 col-lg-6"> <div key={item.id} className="col-12 col-md-6 col-lg-6">
{type === 'gateway' ? renderGatewayCard(item) : renderInterfaceCard(item, serverId)} {type === 'gateway' ? renderGatewayCard(item) :
type === 'interface' ? renderInterfaceCard(item, serverId) :
type === 'pool' ? renderPoolCard(item) : null}
</div> </div>
))} ))}
</div> </div>
@@ -1214,6 +1225,100 @@ function NetworkConfigManager() {
return null; return null;
}; };
// === Рендер карточки IP Pool ===
const renderPoolCard = (pool) => {
const server = getServerInfo(pool.serverId);
const serverCountry = server?.country || '';
// Определяем цвет индикатора (зеленый для IP пулов)
const indicatorColor = 'green';
const IndicatorIcon = IconDatabase;
return (
<div className="card mb-2" style={{ borderRadius: '12px', maxWidth: '100%' }}>
<div className="card-body p-0">
<div className="d-flex align-items-stretch">
{/* Левая часть: тип индикатора */}
<div
className={`d-flex align-items-center justify-content-center px-3 bg-${indicatorColor}-lt`}
style={{
minWidth: '100px',
width: '100px',
borderTopLeftRadius: '11px',
borderBottomLeftRadius: '11px'
}}
>
<div className="text-center">
<div className="mb-1">
<IndicatorIcon size={20} className={`text-${indicatorColor}`} />
</div>
<div className={`fw-bold text-${indicatorColor}`} style={{ fontSize: '0.65rem', letterSpacing: '0.5px' }}>
Pool
</div>
</div>
</div>
{/* Центральная часть: информация */}
<div className="flex-grow-1 py-2 px-3" style={{ minWidth: 0, flex: '1 1 auto', overflow: 'hidden' }}>
{/* Верхняя строка: флаг, название */}
<div className="d-flex align-items-center mb-1">
<div className="d-flex align-items-center" style={{ flex: '0 0 auto', minWidth: 0 }}>
{serverCountry && (
<span className="me-2" style={{ fontSize: '1.25rem', flexShrink: 0 }}>{countryToFlag(serverCountry)}</span>
)}
<div>
<span className="fw-semibold">{pool.name || '—'}</span>
</div>
</div>
</div>
{/* CIDR под названием */}
{pool.cidr && (
<div className="mb-1">
<code className="text-muted" style={{ fontSize: '0.875rem' }}>
{pool.cidr}
</code>
</div>
)}
{/* Нижняя строка: DNS сервера */}
<div className="d-flex align-items-center gap-2 flex-wrap">
{server && (
<code className="text-muted" style={{ fontSize: '0.8rem', wordBreak: 'break-all', overflowWrap: 'break-word' }}>
{server.dns || server.ip || pool.serverId}
</code>
)}
{!server && pool.serverId && (
<code className="text-muted" style={{ fontSize: '0.8rem' }}>
{pool.serverId}
</code>
)}
</div>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2 px-3 border-start" style={{ flexShrink: 0 }}>
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditPool(pool)}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeletePool(pool)}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</div>
</div>
</div>
</div>
);
};
// === Рендер карточки Gateway === // === Рендер карточки Gateway ===
const renderGatewayCard = (gateway) => { const renderGatewayCard = (gateway) => {
const server = getServerInfo(gateway.serverId); const server = getServerInfo(gateway.serverId);
@@ -1578,7 +1683,7 @@ function NetworkConfigManager() {
</div> </div>
{/* Server filter */} {/* Server filter */}
{(activeTab === 'gateways' || activeTab === 'interfaces') && uniqueServersInConfig.length > 0 && ( {(activeTab === 'gateways' || activeTab === 'interfaces' || activeTab === 'pools') && uniqueServersInConfig.length > 0 && (
<div className="col-6 col-md-3 col-lg-2"> <div className="col-6 col-md-3 col-lg-2">
<select <select
className="form-select" className="form-select"
@@ -2002,77 +2107,59 @@ function NetworkConfigManager() {
{activeTab === 'pools' && ( {activeTab === 'pools' && (
<> <>
{filteredPools.length === 0 ? ( {filteredPools.length === 0 ? (
<div className="card"> <div className="empty">
<div className="card-body text-center py-5"> <div className="empty-img">
<div className="mb-3"> <IconDatabase size={48} />
<span className="avatar avatar-xl bg-green-lt"> </div>
<IconDatabase size={40} className="text-green" /> <p className="empty-title">IP пулы не найдены</p>
</span> <p className="empty-subtitle text-muted">
</div> {hasActiveFilters
<h3>IP пулы не найдены</h3> ? 'Попробуйте изменить параметры фильтрации'
<p className="text-muted"> : 'Добавьте пулы IP-адресов для организации сетей'
{hasActiveFilters }
? 'Попробуйте изменить параметры фильтрации' </p>
: 'Добавьте пулы IP-адресов для организации сетей' {!hasActiveFilters && (
} <div className="empty-action">
</p>
{!hasActiveFilters && (
<button className="btn btn-primary" onClick={handleAddPool}> <button className="btn btn-primary" onClick={handleAddPool}>
<IconPlus size={16} className="me-1" /> <IconPlus size={16} className="me-2" />
Добавить IP пул Добавить IP пул
</button> </button>
)} </div>
</div> )}
</div> </div>
) : ( ) : (
<div className="row row-cards"> <>
{filteredPools.map(pool => ( {/* Группировка по серверам */}
<div key={pool.id} className="col-sm-6 col-lg-4"> {(serverFilter || filteredPools.some(p => p.serverId)) && (() => {
<div className="card"> const poolsByServer = {};
<div className="card-body"> filteredPools.forEach(pool => {
<div className="d-flex align-items-center mb-3"> const key = pool.serverId || '__unassigned__';
<span className="avatar avatar-sm bg-green-lt me-2"> if (!poolsByServer[key]) {
<IconDatabase size={16} className="text-green" /> poolsByServer[key] = [];
</span> }
<div className="flex-fill"> poolsByServer[key].push(pool);
<div className="fw-medium">{pool.name || '—'}</div> });
</div>
<div className="dropdown">
<button className="btn btn-ghost-secondary btn-icon btn-sm" data-bs-toggle="dropdown">
<IconEdit size={16} />
</button>
<div className="dropdown-menu dropdown-menu-end">
<button className="dropdown-item" onClick={() => handleEditPool(pool)}>
<IconEdit size={16} className="me-2" />Редактировать
</button>
<button className="dropdown-item text-danger" onClick={() => handleDeletePool(pool)}>
<IconTrash size={16} className="me-2" />Удалить
</button>
</div>
</div>
</div>
<div className="d-flex align-items-center gap-2 mb-2"> return Object.entries(poolsByServer)
<code className="flex-fill">{pool.cidr || '—'}</code> .sort(([a], [b]) => {
{pool.cidr && ( if (a === '__unassigned__') return 1;
<button if (b === '__unassigned__') return -1;
className="btn btn-ghost-secondary btn-icon btn-sm" return a.localeCompare(b);
onClick={() => copyToClipboard(pool.cidr)} })
title="Копировать CIDR" .map(([serverId, pools]) => renderServerGroup(serverId, pools, 'pool'));
> })()}
<IconCopy size={14} />
</button>
)}
</div>
{pool.description && ( {/* Если нет фильтра по серверу и все пулы без сервера - показываем без группировки */}
<div className="text-muted small">{pool.description}</div> {!serverFilter && !filteredPools.some(p => p.serverId) && (
)} <div className="row row-cards">
{filteredPools.map(pool => (
<div key={pool.id} className="col-12 col-md-6 col-lg-6">
{renderPoolCard(pool)}
</div> </div>
</div> ))}
</div> </div>
))} )}
</div> </>
)} )}
</> </>
)} )}