refactor(NetworkConfigManager): migrate parentGatewayId to parentGateways array for recursive gateways; enhance data handling and UI updates for improved clarity and functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s

This commit is contained in:
2026-01-22 15:23:18 +07:00
parent 8f81cf9645
commit 849247002c
+240 -110
View File
@@ -67,7 +67,7 @@ const getEmptyGateway = () => ({
serverId: '',
country: '',
type: 'direct',
parentGatewayId: '', // Для рекурсивных - ID родительского gateway
parentGateways: [], // Для рекурсивных - массив родительских gateway с distance [{id, distance}]
});
const getEmptyInterface = () => ({
@@ -144,8 +144,21 @@ function NetworkConfigManager() {
try {
const response = await api.get('/network-config');
const data = response.data || getDefaultConfig();
// Миграция старых данных: преобразуем parentGatewayId в parentGateways
const migratedGateways = (data.gateways || []).map(gw => {
if (gw.type === 'recursive' && gw.parentGatewayId && (!gw.parentGateways || gw.parentGateways.length === 0)) {
return {
...gw,
parentGateways: [{ id: gw.parentGatewayId, distance: undefined }],
parentGatewayId: undefined // Удаляем старое поле
};
}
return gw;
});
setConfig({
gateways: Array.isArray(data.gateways) ? data.gateways : [],
gateways: Array.isArray(migratedGateways) ? migratedGateways : [],
tunnelInterfaces: Array.isArray(data.tunnelInterfaces) ? data.tunnelInterfaces : [],
ipPools: Array.isArray(data.ipPools) ? data.ipPools : [],
});
@@ -334,7 +347,17 @@ function NetworkConfigManager() {
};
const handleEditGateway = (gateway) => {
setEditingGateway({ ...gateway });
// Миграция старых данных при редактировании
const migratedGateway = gateway.type === 'recursive' && gateway.parentGatewayId && (!gateway.parentGateways || gateway.parentGateways.length === 0)
? {
...gateway,
parentGateways: [{ id: gateway.parentGatewayId, distance: undefined }]
}
: {
...gateway,
parentGateways: gateway.parentGateways || []
};
setEditingGateway(migratedGateway);
setGatewayModalMode('edit');
setGatewayModalOpen(true);
};
@@ -346,36 +369,45 @@ function NetworkConfigManager() {
return;
}
// Очищаем пустые parentGateways и удаляем старое поле parentGatewayId
const cleanedData = {
...gatewayData,
parentGateways: gatewayData.type === 'recursive'
? (gatewayData.parentGateways || []).filter(p => p && p.id)
: [],
parentGatewayId: undefined // Удаляем старое поле
};
if (gatewayModalMode === 'add') {
// Проверка на дубликат IP
const existingGateway = config.gateways.find(g => g.ip === gatewayData.ip.trim());
const existingGateway = config.gateways.find(g => g.ip === cleanedData.ip.trim());
if (existingGateway) {
notify.error(`Gateway с IP ${gatewayData.ip} уже существует`);
notify.error(`Gateway с IP ${cleanedData.ip} уже существует`);
return;
}
setConfig(prev => ({
...prev,
gateways: [...prev.gateways, gatewayData],
gateways: [...prev.gateways, cleanedData],
}));
notify.success('Gateway добавлен');
} else {
// При редактировании ищем по ID, но проверяем, не занят ли новый IP другим gateway
const currentGateway = config.gateways.find(g => g.id === gatewayData.id);
const currentGateway = config.gateways.find(g => g.id === cleanedData.id);
if (!currentGateway) {
notify.error('Gateway не найден');
return;
}
// Если IP изменился, проверяем, не занят ли он другим gateway
if (gatewayData.ip.trim() !== currentGateway.ip &&
config.gateways.some(g => g.id !== gatewayData.id && g.ip === gatewayData.ip.trim())) {
notify.error(`Gateway с IP ${gatewayData.ip} уже существует`);
if (cleanedData.ip.trim() !== currentGateway.ip &&
config.gateways.some(g => g.id !== cleanedData.id && g.ip === cleanedData.ip.trim())) {
notify.error(`Gateway с IP ${cleanedData.ip} уже существует`);
return;
}
setConfig(prev => ({
...prev,
gateways: prev.gateways.map(g => g.id === gatewayData.id ? gatewayData : g),
gateways: prev.gateways.map(g => g.id === cleanedData.id ? cleanedData : g),
}));
notify.success('Gateway обновлён');
}
@@ -586,7 +618,7 @@ function NetworkConfigManager() {
code += `# Связь с сервером: ${iface.server2Name}\n`;
code += `# Local IP: ${iface.localIp}\n`;
code += `# Remote IP: ${iface.remoteIp}\n`;
code += `/ip address add address=${iface.localIp}/32 interface="${iface.interfaceName}" comment="Interface: ${iface.interfaceName} (${iface.type}) to ${iface.server2Name}"\n`;
code += `/ip address add address=${iface.localIp}/30 interface="${iface.interfaceName}" comment="Interface: ${iface.interfaceName} (${iface.type}) to ${iface.server2Name}"\n`;
code += '\n';
});
@@ -641,10 +673,13 @@ function NetworkConfigManager() {
let validRoutesCount = 0;
gateways.forEach(gw => {
const parent = getParentGateway(gw.parentGatewayId);
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gw.parentGateways && Array.isArray(gw.parentGateways) && gw.parentGateways.length > 0
? gw.parentGateways
: (gw.parentGatewayId ? [{ id: gw.parentGatewayId, distance: undefined }] : []);
if (!parent) {
code += `# Gateway "${gw.ip || 'без IP'}" - родительский gateway/интерфейс не найден, пропущен\n`;
if (parentGatewaysList.length === 0) {
code += `# Gateway "${gw.ip || 'без IP'}" - родительские gateway/интерфейсы не указаны, пропущен\n`;
return;
}
@@ -653,43 +688,57 @@ function NetworkConfigManager() {
return;
}
// Определяем IP родителя в зависимости от типа
const parentIp = parent.parentType === 'interface' ? parent.remoteIp : parent.ip;
if (!parentIp) {
const parentType = parent.parentType === 'interface' ? 'интерфейс' : 'gateway';
code += `# Gateway "${gw.ip}" - родительский ${parentType} не имеет IP, пропущен\n`;
return;
}
validRoutesCount++;
// Создаём маршрут для рекурсивного gateway
const parentLabel = parent.parentType === 'interface'
? `интерфейс ${parent.name || parent.interfaceType || 'interface'} (${parent.remoteIp})`
: `gateway ${parent.ip}`;
code += `# Рекурсивный gateway: ${gw.ip}\n`;
code += `# Родительский: ${parentLabel}\n`;
// Формируем gateway: для интерфейсов добавляем имя через %, для gateway - только IP
const gatewayValue = parent.parentType === 'interface'
? `${parentIp}%${parent.name || parent.interfaceType || 'interface'}`
: parentIp;
// Создаём маршрут к IP рекурсивного gateway через родительский
const comment = gw.description
? `${gw.description} -> ${parentIp}`
: `Recursive: ${gw.ip} -> ${parentIp}`;
code += `/ip route add dst-address=${gw.ip}/32 gateway=${gatewayValue} comment="${comment}"\n`;
// Если у рекурсивного gateway есть описание с указанием на default route
if (gw.description && (gw.description.toLowerCase().includes('default') || gw.description.toLowerCase().includes('0.0.0.0'))) {
code += `# Дополнительный default route через рекурсивный gateway\n`;
const defaultComment = gw.description ? `${gw.description} (default)` : `Recursive default: ${gw.ip}`;
code += `/ip route add dst-address=0.0.0.0/0 gateway=${gatewayValue} comment="${defaultComment}"\n`;
}
code += '\n';
// Обрабатываем каждый родительский gateway
parentGatewaysList.forEach((parentRef, parentIndex) => {
const parent = getParentGateway(parentRef.id);
if (!parent) {
code += `# Gateway "${gw.ip}" - родительский gateway/интерфейс "${parentRef.id}" не найден, пропущен\n`;
return;
}
// Определяем IP родителя в зависимости от типа
const parentIp = parent.parentType === 'interface' ? parent.remoteIp : parent.ip;
if (!parentIp) {
const parentType = parent.parentType === 'interface' ? 'интерфейс' : 'gateway';
code += `# Gateway "${gw.ip}" - родительский ${parentType} не имеет IP, пропущен\n`;
return;
}
validRoutesCount++;
// Создаём маршрут для рекурсивного gateway
const parentLabel = parent.parentType === 'interface'
? `интерфейс ${parent.name || parent.interfaceType || 'interface'} (${parent.remoteIp})`
: `gateway ${parent.ip}`;
const distanceSuffix = parentRef.distance !== undefined && parentRef.distance !== null ? ` (distance: ${parentRef.distance})` : '';
code += `# Рекурсивный gateway: ${gw.ip}${distanceSuffix}\n`;
code += `# Родительский: ${parentLabel}\n`;
// Формируем gateway: для интерфейсов добавляем имя через %, для gateway - только IP
const gatewayValue = parent.parentType === 'interface'
? `${parentIp}%${parent.name || parent.interfaceType || 'interface'}`
: parentIp;
// Создаём маршрут к IP рекурсивного gateway через родительский
const comment = gw.description
? `Recursive: ${gw.description} -> ${parentIp}${distanceSuffix}`
: `Recursive: ${gw.ip} -> ${parentIp}${distanceSuffix}`;
// Формируем команду с distance, если указан
const distanceParam = parentRef.distance !== undefined && parentRef.distance !== null ? ` distance=${parentRef.distance}` : '';
code += `/ip route add dst-address=${gw.ip}/32 gateway=${gatewayValue}${distanceParam} comment="${comment}"\n`;
// Если у рекурсивного gateway есть описание с указанием на default route
if (gw.description && (gw.description.toLowerCase().includes('default') || gw.description.toLowerCase().includes('0.0.0.0'))) {
code += `# Дополнительный default route через рекурсивный gateway${distanceSuffix}\n`;
const defaultComment = gw.description ? `Recursive: ${gw.description} (default)${distanceSuffix}` : `Recursive: ${gw.ip} (default)${distanceSuffix}`;
code += `/ip route add dst-address=0.0.0.0/0 gateway=${gatewayValue}${distanceParam} comment="${defaultComment}"\n`;
}
code += '\n';
});
});
code += '# Проверка созданных маршрутов:\n';
@@ -921,9 +970,16 @@ function NetworkConfigManager() {
const gatewayCountry = gateway.country || '';
const displayCountry = gatewayCountry || serverCountry;
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
const parentGateway = gateway.type === 'recursive' && gateway.parentGatewayId
? getParentGateway(gateway.parentGatewayId)
: null;
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gateway.type === 'recursive'
? (gateway.parentGateways && Array.isArray(gateway.parentGateways) && gateway.parentGateways.length > 0
? gateway.parentGateways
: (gateway.parentGatewayId ? [{ id: gateway.parentGatewayId, distance: undefined }] : []))
: [];
// Получаем информацию о первом родительском gateway для отображения
const firstParent = parentGatewaysList.length > 0 ? getParentGateway(parentGatewaysList[0].id) : null;
// Определяем тип индикатора (как в ServerCard)
const indicatorType = gateway.type === 'recursive' ? 'EXIT' : 'JUMP';
@@ -935,12 +991,16 @@ function NetworkConfigManager() {
if (gateway.type === 'direct') {
// Для прямого gateway: 0.0.0.0/0 -> IP адрес
displayText = gateway.ip ? `0.0.0.0/0 → ${gateway.ip}` : '';
} else if (gateway.type === 'recursive' && parentGateway) {
// Для рекурсивного: IP адрес -> IP адрес родительского gateway
const parentIp = parentGateway.parentType === 'interface'
? parentGateway.remoteIp
: parentGateway.ip;
} else if (gateway.type === 'recursive' && firstParent) {
// Для рекурсивного: IP адрес -> IP адрес первого родительского gateway
const parentIp = firstParent.parentType === 'interface'
? firstParent.remoteIp
: firstParent.ip;
displayText = gateway.ip && parentIp ? `${gateway.ip}${parentIp}` : '';
// Если несколько родительских gateway, добавляем информацию
if (parentGatewaysList.length > 1) {
displayText += ` (+${parentGatewaysList.length - 1})`;
}
}
return (
@@ -967,36 +1027,32 @@ function NetworkConfigManager() {
</div>
{/* Центральная часть: информация */}
<div className="flex-grow-1 py-2 px-3" style={{ minWidth: 0, flex: '1 1 auto' }}>
{/* Верхняя строка: флаг, название, провайдер */}
<div className="d-flex align-items-center mb-1 flex-wrap">
<div className="d-flex align-items-center me-3" style={{ flex: '0 0 auto' }}>
<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 }}>
{displayCountry && (
<span className="me-2" style={{ fontSize: '1.25rem', flexShrink: 0 }}>{countryToFlag(displayCountry)}</span>
)}
<div style={{ whiteSpace: 'nowrap' }}>
<span className="fw-semibold">{gateway.description || provider || gateway.ip || '—'}</span>
{provider && (
<>
<span className="text-muted mx-2"></span>
<span className="text-muted">{provider}</span>
</>
)}
<div>
<span className="fw-semibold">{gateway.description || gateway.ip || '—'}</span>
</div>
</div>
{/* Зеленый блок с маршрутом */}
{displayText && (
<span className="badge bg-success-lt text-success ms-auto" style={{ whiteSpace: 'nowrap', flexShrink: 0, fontSize: '0.875rem' }}>
</div>
{/* Маршрут ip->ip сразу под названием */}
{displayText && (
<div className="mb-1">
<span className="badge bg-success-lt text-success" style={{ whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{displayText}
</span>
)}
</div>
</div>
)}
{/* Нижняя строка: DNS + badges */}
<div className="d-flex align-items-center gap-2 flex-wrap">
{server && (
<code className="text-muted" style={{ fontSize: '0.8rem', wordBreak: 'break-all' }}>
<code className="text-muted" style={{ fontSize: '0.8rem', wordBreak: 'break-all', overflowWrap: 'break-word' }}>
{server.dns || server.ip || gateway.serverId}
</code>
)}
@@ -1102,30 +1158,32 @@ function NetworkConfigManager() {
</div>
{/* Центральная часть: информация */}
<div className="flex-grow-1 py-2 px-3" style={{ minWidth: 0, flex: '1 1 auto' }}>
<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 flex-wrap">
<div className="d-flex align-items-center me-3" style={{ flex: '0 0 auto' }}>
<div className="d-flex align-items-center mb-1">
<div className="d-flex align-items-center" style={{ flex: '0 0 auto', minWidth: 0 }}>
{displayCountry && (
<span className="me-2" style={{ fontSize: '1.25rem', flexShrink: 0 }}>{countryToFlag(displayCountry)}</span>
)}
<div style={{ whiteSpace: 'nowrap' }}>
<div>
<span className="fw-semibold">{interfaceName}</span>
</div>
</div>
{/* Зеленый блок с IP */}
{displayIp && displayIp !== '—' && (
<span className="badge bg-success-lt text-success ms-auto" style={{ whiteSpace: 'nowrap', flexShrink: 0, fontSize: '0.875rem' }}>
{displayIp}
</span>
)}
</div>
{/* Маршрут между Local IP и Remote IP сразу под названием */}
{displayLocalIp !== '—' && displayRemoteIp !== '—' && (
<div className="mb-1">
<span className="badge bg-success-lt text-success" style={{ whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{displayLocalIp} {displayRemoteIp}
</span>
</div>
)}
{/* Нижняя строка: DNS + badges */}
<div className="d-flex align-items-center gap-2 flex-wrap">
{displayServerName && (
<code className="text-muted" style={{ fontSize: '0.8rem', wordBreak: 'break-all' }}>
<code className="text-muted" style={{ fontSize: '0.8rem', wordBreak: 'break-all', overflowWrap: 'break-word' }}>
{displayServerName}
</code>
)}
@@ -1447,9 +1505,13 @@ function NetworkConfigManager() {
const gatewayCountry = gateway.country || '';
const displayCountry = gatewayCountry || serverCountry;
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
const parentGateway = gateway.type === 'recursive' && gateway.parentGatewayId
? getParentGateway(gateway.parentGatewayId)
: null;
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
const parentGatewaysList = gateway.type === 'recursive'
? (gateway.parentGateways && Array.isArray(gateway.parentGateways) && gateway.parentGateways.length > 0
? gateway.parentGateways
: (gateway.parentGatewayId ? [{ id: gateway.parentGatewayId, distance: undefined }] : []))
: [];
const firstParent = parentGatewaysList.length > 0 ? getParentGateway(parentGatewaysList[0].id) : null;
return (
<tr key={gateway.id}>
@@ -1482,14 +1544,17 @@ function NetworkConfigManager() {
<span className={`badge bg-${gatewayType.color}-lt text-${gatewayType.color}`}>
{gatewayType.label}
</span>
{parentGateway && (
{firstParent && (
<div className="text-muted small mt-1">
{parentGateway.parentType === 'interface'
? `Интерфейс ${parentGateway.name || parentGateway.interfaceType || 'interface'} (${parentGateway.remoteIp || '—'})`
: parentGateway.ip
? `Gateway ${parentGateway.ip}`
{firstParent.parentType === 'interface'
? `Интерфейс ${firstParent.name || firstParent.interfaceType || 'interface'} (${firstParent.remoteIp || '—'})`
: firstParent.ip
? `Gateway ${firstParent.ip}`
: 'Gateway (не указан IP)'
}
{parentGatewaysList.length > 1 && (
<span className="ms-1">(+{parentGatewaysList.length - 1})</span>
)}
</div>
)}
</td>
@@ -1826,7 +1891,7 @@ function NetworkConfigManager() {
name="type"
type="select"
value={editingGateway.type}
onChange={(val) => setEditingGateway({ ...editingGateway, type: val, parentGatewayId: val === 'direct' ? '' : editingGateway.parentGatewayId })}
onChange={(val) => setEditingGateway({ ...editingGateway, type: val, parentGateways: val === 'direct' ? [] : (editingGateway.parentGateways || []) })}
options={GATEWAY_TYPES.map(t => ({ value: t.value, label: `${t.label} - ${t.description}` }))}
required
/>
@@ -1843,17 +1908,82 @@ function NetworkConfigManager() {
</div>
{editingGateway.type === 'recursive' && (
<div className="col-12">
<label className="form-label">Родительский gateway или интерфейс</label>
<GatewayAutocompleteInput
value={editingGateway.parentGatewayId}
onChange={(val) => setEditingGateway({ ...editingGateway, parentGatewayId: val })}
gateways={config.gateways}
interfaces={config.tunnelInterfaces}
excludeGatewayId={editingGateway.id}
placeholder="Выберите родительский gateway или интерфейс..."
/>
<label className="form-label">Родительские gateway или интерфейсы</label>
<div className="d-flex flex-column gap-2">
{(editingGateway.parentGateways || []).map((parent, index) => {
const parentInfo = getParentGateway(parent.id);
return (
<div key={index} className="card card-sm border">
<div className="card-body p-2">
<div className="row g-2 align-items-center">
<div className="col-md-8">
<GatewayAutocompleteInput
value={parent.id}
onChange={(val) => {
const newParents = [...(editingGateway.parentGateways || [])];
newParents[index] = { ...newParents[index], id: val };
setEditingGateway({ ...editingGateway, parentGateways: newParents });
}}
gateways={config.gateways}
interfaces={config.tunnelInterfaces}
excludeGatewayId={editingGateway.id}
placeholder="Выберите родительский gateway или интерфейс..."
/>
</div>
<div className="col-md-3">
<FormField
label="Distance"
name="distance"
type="number"
value={parent.distance || ''}
onChange={(val) => {
const newParents = [...(editingGateway.parentGateways || [])];
newParents[index] = { ...newParents[index], distance: val ? parseInt(val) : undefined };
setEditingGateway({ ...editingGateway, parentGateways: newParents });
}}
placeholder="1"
/>
</div>
<div className="col-md-1 d-flex align-items-end">
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => {
const newParents = (editingGateway.parentGateways || []).filter((_, i) => i !== index);
setEditingGateway({ ...editingGateway, parentGateways: newParents });
}}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</div>
{parentInfo && (
<div className="mt-2 small text-muted">
{parentInfo.parentType === 'interface'
? `Интерфейс ${parentInfo.name || parentInfo.interfaceType || 'interface'} (${parentInfo.remoteIp || '—'})`
: `Gateway ${parentInfo.ip || '—'}`
}
</div>
)}
</div>
</div>
);
})}
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => {
const newParents = [...(editingGateway.parentGateways || []), { id: '', distance: undefined }];
setEditingGateway({ ...editingGateway, parentGateways: newParents });
}}
>
<IconPlus size={16} className="me-1" />
Добавить родительский gateway
</button>
</div>
<div className="form-text">
Рекурсивный gateway может ссылаться на прямой gateway или на интерфейс (по его remote IP)
Рекурсивный gateway может ссылаться на несколько прямых gateway или интерфейсов. Distance определяет приоритет маршрута (меньше = выше приоритет).
</div>
</div>
)}