feat(NetworkConfigManager): add support for dual server configurations in network interfaces; enhance MikroTik code generation and UI to display both servers
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s

This commit is contained in:
2026-01-22 13:07:39 +07:00
parent bf08e91d4b
commit cf1dbfce37
2 changed files with 245 additions and 75 deletions
+6 -6
View File
@@ -57,8 +57,8 @@
"type": "GRE",
"localIp": "10.10.0.1",
"remoteIp": "10.10.0.2",
"port": "",
"serverId": "SWE-HIPHOST"
"serverId": "SWE-HIPHOST",
"serverId2": "DE-FRANKFURT"
},
{
"id": "if-wg-swe-nl",
@@ -66,7 +66,8 @@
"type": "WireGuard",
"localIp": "10.20.0.1",
"remoteIp": "10.20.0.2",
"serverId": "SWE-HIPHOST"
"serverId": "SWE-HIPHOST",
"serverId2": "NL-AMSTERDAM"
},
{
"id": "if-gre-de-us",
@@ -74,8 +75,8 @@
"type": "GRE",
"localIp": "10.10.1.1",
"remoteIp": "10.10.1.2",
"port": "",
"serverId": "DE-FRANKFURT"
"serverId": "DE-FRANKFURT",
"serverId2": "US-NEWYORK"
},
{
"id": "if-ipsec-nl-jp",
@@ -83,7 +84,6 @@
"type": "IPSec",
"localIp": "10.30.0.1",
"remoteIp": "10.30.0.2",
"port": "",
"serverId": "NL-AMSTERDAM"
}
],
+239 -69
View File
@@ -77,6 +77,7 @@ const getEmptyInterface = () => ({
localIp: '',
remoteIp: '',
serverId: '',
serverId2: '', // Второй сервер (опциональный)
});
const getEmptyIpPool = () => ({
@@ -298,9 +299,17 @@ function NetworkConfigManager() {
const interfacesByServer = useMemo(() => {
const grouped = {};
filteredInterfaces.forEach(iface => {
const key = iface.serverId || '__unassigned__';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(iface);
// Группируем по первому серверу
const key1 = iface.serverId || '__unassigned__';
if (!grouped[key1]) grouped[key1] = [];
grouped[key1].push(iface);
// Если есть второй сервер, также добавляем в его группу
if (iface.serverId2) {
const key2 = iface.serverId2;
if (!grouped[key2]) grouped[key2] = [];
grouped[key2].push(iface);
}
});
return grouped;
}, [filteredInterfaces]);
@@ -492,6 +501,70 @@ function NetworkConfigManager() {
}
};
// === Генерация кода MikroTik для настройки IP адресов интерфейсов ===
const generateMikrotikInterfaceAddresses = () => {
const interfacesWithBothServers = (config.tunnelInterfaces || []).filter(i =>
i.serverId && i.serverId2 && i.localIp && i.remoteIp
);
if (interfacesWithBothServers.length === 0) {
return '';
}
let code = '\n# === Настройка IP адресов интерфейсов на обоих серверах ===\n';
code += '# Эти команды нужно выполнить на соответствующих серверах\n\n';
// Группируем по серверам
const interfacesByServer = {};
interfacesWithBothServers.forEach(iface => {
const server1 = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
if (!server1 || !server2) return;
const server1Name = server1.dns || server1.ip || iface.serverId;
const server2Name = server2.dns || server2.ip || iface.serverId2;
const interfaceName = iface.name || `${iface.type}-tunnel`;
// Для первого сервера - используем localIp
if (!interfacesByServer[server1Name]) {
interfacesByServer[server1Name] = [];
}
interfacesByServer[server1Name].push({
interfaceName,
ip: iface.localIp,
type: iface.type,
remoteIp: iface.remoteIp,
server2Name
});
// Для второго сервера - используем remoteIp
if (!interfacesByServer[server2Name]) {
interfacesByServer[server2Name] = [];
}
interfacesByServer[server2Name].push({
interfaceName,
ip: iface.remoteIp,
type: iface.type,
remoteIp: iface.localIp, // Для второго сервера remoteIp - это localIp первого
server2Name: server1Name
});
});
Object.entries(interfacesByServer).forEach(([serverName, interfaces]) => {
code += `\n# --- Команды для сервера: ${serverName} ---\n`;
interfaces.forEach(iface => {
code += `# Интерфейс: ${iface.interfaceName} (${iface.type}) - связь с ${iface.server2Name}\n`;
code += `/ip address add address=${iface.ip}/32 interface="${iface.interfaceName}" comment="Interface: ${iface.interfaceName} (${iface.type}) to ${iface.server2Name}"\n`;
});
});
code += '\n# Проверка настроенных адресов:\n';
code += '# /ip address print\n';
return code;
};
// === Генерация кода MikroTik для рекурсивных маршрутов ===
const generateMikrotikRecursiveRoutes = () => {
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
@@ -575,6 +648,12 @@ function NetworkConfigManager() {
code += '# /ip route print where comment~"Recursive"\n';
code += `\n# Всего создано маршрутов: ${validRoutesCount}\n`;
// Добавляем код для настройки IP адресов интерфейсов
const interfaceAddressesCode = generateMikrotikInterfaceAddresses();
if (interfaceAddressesCode) {
code += interfaceAddressesCode;
}
return code;
};
@@ -813,73 +892,96 @@ function NetworkConfigManager() {
};
// === Рендер карточки Interface ===
const renderInterfaceCard = (iface) => (
<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)}`} />
</span>
<div className="flex-fill">
<div className="fw-medium">{iface.name || '—'}</div>
const renderInterfaceCard = (iface) => {
const server = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
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)}`} />
</span>
<div className="flex-fill">
<div className="fw-medium">{iface.name || '—'}</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} />
</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" />Удалить
</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={() => 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>
<div className="row g-2 small">
<div className="col-6">
<div className="text-muted">Local IP</div>
<div className="d-flex align-items-center gap-1">
<code className="text-truncate">{iface.localIp || '—'}</code>
{iface.localIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.localIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
<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">{iface.localIp || '—'}</code>
{iface.localIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.localIp)}
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">{iface.remoteIp || '—'}</code>
{iface.remoteIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.remoteIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</div>
</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">{iface.remoteIp || '—'}</code>
{iface.remoteIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.remoteIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</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>
</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>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
);
};
return (
<div className="network-config-manager">
@@ -1307,13 +1409,14 @@ function NetworkConfigManager() {
<th>Тип</th>
<th>Local IP</th>
<th>Remote IP</th>
<th>Сервер</th>
<th>Серверы</th>
<th className="text-end" style={{ width: 100 }}>Действия</th>
</tr>
</thead>
<tbody>
{filteredInterfaces.map(iface => {
const server = getServerInfo(iface.serverId);
const server2 = getServerInfo(iface.serverId2);
return (
<tr key={iface.id}>
<td><strong>{iface.name || '—'}</strong></td>
@@ -1336,18 +1439,39 @@ function NetworkConfigManager() {
)}
</code>
</td>
<td><code>{iface.remoteIp || '—'}</code></td>
<td>
{server ? (
<div className="d-flex align-items-center">
<IconServer size={14} className="text-muted me-1" />
<span>{server.dns || server.ip}</span>
</div>
) : iface.serverId ? (
<span className="text-muted">{iface.serverId}</span>
) : (
<span className="text-muted"></span>
)}
<code className="d-flex align-items-center gap-1">
{iface.remoteIp || '—'}
{iface.remoteIp && (
<button
className="btn btn-ghost-secondary btn-icon p-0"
onClick={() => copyToClipboard(iface.remoteIp)}
style={{ width: 20, height: 20 }}
>
<IconCopy size={12} />
</button>
)}
</code>
</td>
<td>
<div>
{server ? (
<div className="d-flex align-items-center">
<IconServer size={14} className="text-muted me-1" />
<span>{server.dns || server.ip}</span>
</div>
) : iface.serverId ? (
<span className="text-muted">{iface.serverId}</span>
) : (
<span className="text-muted"></span>
)}
{server2 && (
<div className="d-flex align-items-center mt-1">
<IconServer size={14} className="text-muted me-1" />
<span className="text-muted small">{server2.dns || server2.ip}</span>
</div>
)}
</div>
</td>
<td className="text-end">
<div className="btn-list gap-1 mb-0 justify-content-end">
@@ -1617,7 +1741,7 @@ function NetworkConfigManager() {
placeholder="10.10.0.1"
/>
</div>
<div className="col-md-6">
<div className="col-md-4">
<FormField
label="Remote IP"
name="remoteIp"
@@ -1626,6 +1750,52 @@ function NetworkConfigManager() {
placeholder="10.10.0.2"
/>
</div>
<div className="col-12">
<label className="form-label">Второй сервер (опционально)</label>
<ServerAutocompleteInput
value={editingInterface.serverId2 || ''}
onChange={(val) => setEditingInterface({ ...editingInterface, serverId2: val })}
servers={servers.filter(s => s.id !== editingInterface.serverId && s.ip !== editingInterface.serverId)}
placeholder="Выберите второй сервер (для понимания связи между серверами)..."
/>
<div className="form-text">
Укажите второй сервер, если интерфейс связывает два сервера
</div>
</div>
{editingInterface.serverId && (() => {
const server = getServerInfo(editingInterface.serverId);
return server ? (
<div className="col-12">
<div className="alert alert-info mb-0">
<div className="d-flex align-items-center">
<IconServer size={16} className="me-2" />
<div>
<strong>Сервер 1:</strong> {server.dns || server.ip}
{server.provider && <span className="ms-2"> {server.provider}</span>}
{server.country && <span className="ms-2"> {countryToFlag(server.country)} {server.country}</span>}
</div>
</div>
</div>
</div>
) : null;
})()}
{editingInterface.serverId2 && (() => {
const server2 = getServerInfo(editingInterface.serverId2);
return server2 ? (
<div className="col-12">
<div className="alert alert-info mb-0">
<div className="d-flex align-items-center">
<IconServer size={16} className="me-2" />
<div>
<strong>Сервер 2:</strong> {server2.dns || server2.ip}
{server2.provider && <span className="ms-2"> {server2.provider}</span>}
{server2.country && <span className="ms-2"> {countryToFlag(server2.country)} {server2.country}</span>}
</div>
</div>
</div>
</div>
) : null;
})()}
</div>
)}
</FormModal>