refactor(NetworkConfigManager): enhance gateway and interface card rendering with improved layout and indicator display; update MikroTik code generation for gateway values
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s

This commit is contained in:
2026-01-22 14:31:08 +07:00
parent 0bd2a07dd2
commit 00bf38fbb9
+150 -151
View File
@@ -671,17 +671,22 @@ function NetworkConfigManager() {
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=${parentIp} comment="${comment}"\n`;
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=${parentIp} comment="${defaultComment}"\n`;
code += `/ip route add dst-address=0.0.0.0/0 gateway=${gatewayValue} comment="${defaultComment}"\n`;
}
code += '\n';
});
@@ -860,7 +865,7 @@ function NetworkConfigManager() {
</div>
{/* Items */}
<div className="row row-cards">
<div className="d-flex flex-column">
{items.map(item => (
type === 'gateway' ? renderGatewayCard(item) : renderInterfaceCard(item, serverId)
))}
@@ -917,79 +922,87 @@ function NetworkConfigManager() {
? getParentGateway(gateway.parentGatewayId)
: null;
// Определяем тип индикатора
const indicatorType = gateway.type === 'recursive' ? 'EXIT' : 'JUMP';
const indicatorColor = gateway.type === 'recursive' ? 'red' : 'blue';
const indicatorIcon = gateway.type === 'recursive' ? IconRouter : IconWorld;
// Определяем отображаемый IP/домен для зеленого блока
const displayIp = parentGateway
? (parentGateway.parentType === 'interface'
? (parentGateway.name || parentGateway.interfaceType || 'interface')
: parentGateway.ip)
: gateway.ip;
return (
<div key={gateway.id} className="col-sm-6 col-lg-4">
<div className="card card-sm">
<div className="card-body">
<div className="d-flex align-items-center mb-2">
<span className={`avatar avatar-sm bg-${gatewayType.color}-lt me-2`}>
<IconWorld size={16} className={`text-${gatewayType.color}`} />
</span>
<div className="flex-fill">
<div className="d-flex align-items-center gap-1">
<code className="fw-medium">{gateway.ip || '—'}</code>
<span className={`badge bg-${gatewayType.color}-lt text-${gatewayType.color}`} style={{ fontSize: '0.65rem' }}>
{gatewayType.label}
</span>
</div>
<div className="text-muted small">
{gateway.description || provider || '—'}
</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={() => handleEditGateway(gateway)}>
<IconEdit size={16} className="me-2" />Редактировать
</button>
<button className="dropdown-item text-danger" onClick={() => handleDeleteGateway(gateway)}>
<IconTrash size={16} className="me-2" />Удалить
</button>
</div>
</div>
</div>
<div className="d-flex align-items-center gap-2 mb-2">
<code className="flex-fill text-truncate" title={gateway.ip}>
{gateway.ip || '—'}
</code>
{gateway.ip && (
<button
className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={() => copyToClipboard(gateway.ip)}
title="Копировать IP"
>
<IconCopy size={14} />
</button>
)}
</div>
{displayCountry && (
<div className="d-flex align-items-center gap-1 text-muted small mb-1">
<span>{countryToFlag(displayCountry)}</span>
<span>{displayCountry}</span>
{gatewayCountry && serverCountry && gatewayCountry !== serverCountry && (
<span className="text-muted" style={{ fontSize: '0.7rem' }}>(сервер: {serverCountry})</span>
<div key={gateway.id} className="card mb-2">
<div className="card-body">
<div className="d-flex align-items-center">
{/* Индикатор типа */}
<div className={`d-flex flex-column align-items-center me-3`} style={{ minWidth: '60px' }}>
<div className={`avatar avatar-md bg-${indicatorColor}-lt mb-1`}>
{indicatorType === 'EXIT' ? (
<IconRouter size={20} className={`text-${indicatorColor}`} />
) : (
<IconWorld size={20} className={`text-${indicatorColor}`} />
)}
</div>
)}
<span className={`text-${indicatorColor} fw-bold`} style={{ fontSize: '0.7rem' }}>{indicatorType}</span>
</div>
{parentGateway && (
<div className="d-flex align-items-center gap-1 text-muted small mb-1">
<span className="text-muted"></span>
<span>
Родитель: {parentGateway.parentType === 'interface'
? `Интерфейс ${parentGateway.name || parentGateway.interfaceType || 'interface'} (${parentGateway.remoteIp || '—'})`
: parentGateway.ip
? `Gateway ${parentGateway.ip}`
: 'Gateway (не указан IP)'
}
</span>
{/* Флаг и название */}
<div className="d-flex align-items-center me-3" style={{ minWidth: '200px' }}>
{displayCountry && (
<span className="me-2" style={{ fontSize: '1.2rem' }}>{countryToFlag(displayCountry)}</span>
)}
<div>
<div className="fw-bold">{gateway.description || provider || gateway.ip || '—'}</div>
{server && (
<div className="text-muted small">{server.dns || server.ip || gateway.serverId}</div>
)}
</div>
</div>
{/* Теги */}
<div className="d-flex align-items-center gap-2 me-3">
<span className={`badge bg-${gatewayType.color}-lt text-${gatewayType.color}`}>
{gatewayType.label}
</span>
</div>
{/* Зеленый блок с IP/доменом */}
{displayIp && (
<div className="bg-success-lt rounded px-3 py-2 me-3 d-flex align-items-center">
<span className="text-success me-1"></span>
<code className="text-success fw-medium">{displayIp}</code>
<button
className="btn btn-ghost-success btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(displayIp)}
title="Копировать"
style={{ padding: '2px', width: '20px', height: '20px' }}
>
<IconCopy size={12} />
</button>
</div>
)}
{/* Кнопки действий */}
<div className="ms-auto d-flex align-items-center gap-2">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditGateway(gateway)}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteGateway(gateway)}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</div>
</div>
</div>
</div>
@@ -1002,12 +1015,10 @@ function NetworkConfigManager() {
const server2 = getServerInfo(iface.serverId2);
// Определяем, для какого сервера отображается карточка
// Сравниваем currentServerId с serverId2 используя функцию getServerInfo для надежности
const isForServer2 = currentServerId && iface.serverId2 && (() => {
const currentServer = getServerInfo(currentServerId);
const targetServer2 = getServerInfo(iface.serverId2);
if (!currentServer || !targetServer2) return false;
// Сравниваем по id, ip или dns
return (
currentServer.id === targetServer2.id ||
currentServer.ip === targetServer2.ip ||
@@ -1025,94 +1036,82 @@ function NetworkConfigManager() {
: (iface.name || '—');
// IP адреса также зависят от того, для какого сервера отображается
// Для второго сервера localIp и remoteIp меняются местами
const displayLocalIp = isForServer2 ? (iface.remoteIp || '—') : (iface.localIp || '—');
const displayRemoteIp = isForServer2 ? (iface.localIp || '—') : (iface.remoteIp || '—');
// Определяем сервер для отображения
const displayServer = isForServer2 ? server2 : server;
const displayServerName = displayServer?.dns || displayServer?.ip || (isForServer2 ? iface.serverId2 : iface.serverId);
const displayCountry = displayServer?.country || '';
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
return (
<div key={iface.id} className="col-sm-6 col-lg-4">
<div className="card card-sm">
<div className="card-body">
<div className="d-flex align-items-center mb-2">
<span className={`avatar avatar-sm bg-${getInterfaceTypeColor(iface.type)}-lt me-2`}>
<IconRouter size={16} className={`text-${getInterfaceTypeColor(iface.type)}`} />
<div key={iface.id} className="card mb-2">
<div className="card-body">
<div className="d-flex align-items-center">
{/* Индикатор типа */}
<div className={`d-flex flex-column align-items-center me-3`} style={{ minWidth: '60px' }}>
<div className={`avatar avatar-md bg-${interfaceTypeColor}-lt mb-1`}>
<IconRouter size={20} className={`text-${interfaceTypeColor}`} />
</div>
<span className={`text-${interfaceTypeColor} fw-bold`} style={{ fontSize: '0.7rem' }}>{iface.type}</span>
</div>
{/* Флаг и название */}
<div className="d-flex align-items-center me-3" style={{ minWidth: '200px' }}>
{displayCountry && (
<span className="me-2" style={{ fontSize: '1.2rem' }}>{countryToFlag(displayCountry)}</span>
)}
<div>
<div className="fw-bold">{interfaceName}</div>
{displayServerName && (
<div className="text-muted small">{displayServerName}</div>
)}
</div>
</div>
{/* Теги */}
<div className="d-flex align-items-center gap-2 me-3">
<span className={`badge bg-${interfaceTypeColor}-lt text-${interfaceTypeColor}`}>
{iface.type}
</span>
<div className="flex-fill">
<div className="fw-medium">{interfaceName}</div>
<div className="text-muted small">
<span className={`badge bg-${getInterfaceTypeColor(iface.type)}-lt text-${getInterfaceTypeColor(iface.type)}`}>
{iface.type}
</span>
</div>
</div>
<div className="dropdown">
<button className="btn btn-ghost-secondary btn-icon btn-sm" data-bs-toggle="dropdown">
<IconEdit size={16} />
</div>
{/* Зеленый блок с IP/доменом */}
{displayIp && displayIp !== '—' && (
<div className="bg-success-lt rounded px-3 py-2 me-3 d-flex align-items-center">
<span className="text-success me-1"></span>
<code className="text-success fw-medium">{displayIp}</code>
<button
className="btn btn-ghost-success btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(displayIp)}
title="Копировать"
style={{ padding: '2px', width: '20px', height: '20px' }}
>
<IconCopy size={12} />
</button>
<div className="dropdown-menu dropdown-menu-end">
<button className="dropdown-item" onClick={() => handleEditInterface(iface)}>
<IconEdit size={16} className="me-2" />Редактировать
</button>
<button className="dropdown-item text-danger" onClick={() => handleDeleteInterface(iface)}>
<IconTrash size={16} className="me-2" />Удалить
</button>
</div>
</div>
</div>
)}
<div className="row g-2 small mb-2">
<div className="col-6">
<div className="text-muted">Local IP</div>
<div className="d-flex align-items-center gap-1">
<code className="text-truncate">{displayLocalIp}</code>
{displayLocalIp && displayLocalIp !== '—' && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(displayLocalIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</div>
</div>
<div className="col-6">
<div className="text-muted">Remote IP</div>
<div className="d-flex align-items-center gap-1">
<code className="text-truncate">{displayRemoteIp}</code>
{displayRemoteIp && displayRemoteIp !== '—' && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(displayRemoteIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</div>
</div>
</div>
{/* Серверы */}
<div className="small">
{server && (
<div className="d-flex align-items-center gap-1 mb-1">
<IconServer size={12} className="text-muted" />
<span className="text-muted">Сервер 1:</span>
<span>{server.dns || server.ip}</span>
{iface.name && <span className="text-muted ms-1">({iface.name})</span>}
</div>
)}
{server2 && (
<div className="d-flex align-items-center gap-1">
<IconServer size={12} className="text-muted" />
<span className="text-muted">Сервер 2:</span>
<span>{server2.dns || server2.ip}</span>
{(iface.name2 || iface.name) && (
<span className="text-muted ms-1">({iface.name2 || iface.name})</span>
)}
</div>
)}
{/* Кнопки действий */}
<div className="ms-auto d-flex align-items-center gap-2">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEditInterface(iface)}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteInterface(iface)}
>
<IconTrash size={16} className="me-1" />
Удалить
</button>
</div>
</div>
</div>