From 113fa59a63095bfe0e0de221863e567d23c85cec Mon Sep 17 00:00:00 2001 From: shats Date: Thu, 5 Mar 2026 15:31:41 +0700 Subject: [PATCH] feat(network-config-manager): implement OSPF candidate management and normalization for interface templates --- frontend/src/NetworkConfigManager.jsx | 271 ++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/frontend/src/NetworkConfigManager.jsx b/frontend/src/NetworkConfigManager.jsx index 7bb86e1..b6d2eee 100644 --- a/frontend/src/NetworkConfigManager.jsx +++ b/frontend/src/NetworkConfigManager.jsx @@ -125,6 +125,135 @@ const GATEWAY_TEMPLATES = [ }, ]; +const OSPF_COST_STEP = 10; + +function getServerRefMap(servers = []) { + const byRef = new Map(); + (servers || []).forEach((server) => { + if (!server || typeof server !== 'object') return; + if (server.id) byRef.set(String(server.id), server); + if (server.ip) byRef.set(String(server.ip), server); + if (server.dns) byRef.set(String(server.dns), server); + }); + return byRef; +} + +function getHomeOspfCandidates(tunnelInterfaces = [], servers = []) { + const serverByRef = getServerRefMap(servers); + const candidatesByServer = {}; + + (tunnelInterfaces || []).forEach((iface) => { + if (String(iface?.type || '').toUpperCase() !== 'GRE') return; + const tunnelId = String(iface?.id || ''); + + const addCandidate = (serverRef, side) => { + const server = serverByRef.get(String(serverRef || '')); + if (!server || String(server.type || '').toLowerCase() !== 'home') return; + + const serverId = String(server.id || serverRef || ''); + if (!serverId) return; + + const interfaceName = String( + side === 'left' ? (iface?.name || '') : (iface?.name2 || iface?.name || '') + ).trim(); + if (!interfaceName) return; + + const uniqueKey = `${tunnelId}:${side}:${interfaceName}`; + if (!candidatesByServer[serverId]) candidatesByServer[serverId] = []; + candidatesByServer[serverId].push({ + key: uniqueKey, + serverId, + tunnelInterfaceId: tunnelId, + interfaceName, + }); + }; + + addCandidate(iface?.serverId, 'left'); + addCandidate(iface?.serverId2, 'right'); + }); + + Object.keys(candidatesByServer).forEach((serverId) => { + const unique = new Map(); + candidatesByServer[serverId].forEach((candidate) => { + if (!unique.has(candidate.key)) unique.set(candidate.key, candidate); + }); + candidatesByServer[serverId] = Array.from(unique.values()); + }); + + return candidatesByServer; +} + +function areOspfTemplateListsEqual(prevList = [], nextList = []) { + if (prevList.length !== nextList.length) return false; + for (let i = 0; i < prevList.length; i += 1) { + const prev = prevList[i]; + const next = nextList[i]; + if ( + String(prev?.id || '') !== String(next?.id || '') || + String(prev?.key || '') !== String(next?.key || '') || + String(prev?.serverId || '') !== String(next?.serverId || '') || + String(prev?.tunnelInterfaceId || '') !== String(next?.tunnelInterfaceId || '') || + String(prev?.interfaceName || '') !== String(next?.interfaceName || '') || + Number(prev?.cost || 0) !== Number(next?.cost || 0) + ) { + return false; + } + } + return true; +} + +function normalizeOspfTemplates(rawTemplates = [], candidatesByServer = {}) { + const normalized = []; + + Object.entries(candidatesByServer).forEach(([serverId, candidates]) => { + const byKey = new Map(candidates.map((item) => [String(item.key), item])); + const existing = (rawTemplates || []) + .filter((item) => String(item?.serverId || '') === String(serverId) && byKey.has(String(item?.key || ''))) + .sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0)); + + const usedKeys = new Set(); + const ordered = []; + + existing.forEach((item) => { + const key = String(item?.key || ''); + if (usedKeys.has(key)) return; + usedKeys.add(key); + ordered.push({ + ...item, + ...byKey.get(key), + }); + }); + + candidates.forEach((candidate) => { + if (usedKeys.has(candidate.key)) return; + usedKeys.add(candidate.key); + ordered.push({ + id: `ospf-${serverId}-${candidate.key}`, + key: candidate.key, + serverId: candidate.serverId, + tunnelInterfaceId: candidate.tunnelInterfaceId, + interfaceName: candidate.interfaceName, + }); + }); + + const withCosts = ordered.map((item, idx) => ({ + ...item, + cost: (ordered.length - idx) * OSPF_COST_STEP, + })); + normalized.push(...withCosts); + }); + + return normalized; +} + +function moveInArray(arr, fromIndex, toIndex) { + if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr; + const copy = [...arr]; + const [moved] = copy.splice(fromIndex, 1); + copy.splice(toIndex, 0, moved); + return copy; +} + // Пустые объекты const getEmptyGateway = () => ({ id: `gw-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`, @@ -163,6 +292,7 @@ const getDefaultConfig = () => ({ gateways: [], tunnelInterfaces: [], ipPools: [], + ospfInterfaceTemplates: [], }); function NetworkConfigManager() { @@ -222,6 +352,7 @@ function NetworkConfigManager() { const [templateMtu, setTemplateMtu] = useState(''); // MTU для шаблона const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания + const [ospfDraggingItem, setOspfDraggingItem] = useState(null); // === IPSec Passwords Management === const [ipsecPasswords, setIpsecPasswords] = useState([]); @@ -285,6 +416,7 @@ function NetworkConfigManager() { gateways: Array.isArray(migratedGateways) ? migratedGateways : [], tunnelInterfaces: Array.isArray(data.tunnelInterfaces) ? data.tunnelInterfaces : [], ipPools: Array.isArray(data.ipPools) ? data.ipPools : [], + ospfInterfaceTemplates: Array.isArray(data.ospfInterfaceTemplates) ? data.ospfInterfaceTemplates : [], }); } catch (error) { if (error.response?.status !== 404) { @@ -481,6 +613,78 @@ function NetworkConfigManager() { return result; }, [config.ipPools, serverFilter, searchTerm, servers]); + const homeOspfCandidatesByServer = useMemo( + () => getHomeOspfCandidates(config.tunnelInterfaces || [], servers), + [config.tunnelInterfaces, servers] + ); + + useEffect(() => { + setConfig((prev) => { + const prevTemplates = Array.isArray(prev?.ospfInterfaceTemplates) ? prev.ospfInterfaceTemplates : []; + const nextTemplates = normalizeOspfTemplates(prevTemplates, homeOspfCandidatesByServer); + if (areOspfTemplateListsEqual(prevTemplates, nextTemplates)) return prev; + return { + ...prev, + ospfInterfaceTemplates: nextTemplates, + }; + }); + }, [homeOspfCandidatesByServer]); + + const ospfTemplatesByHomeServer = useMemo(() => { + const grouped = {}; + (config.ospfInterfaceTemplates || []).forEach((item) => { + const sid = String(item?.serverId || ''); + if (!sid) return; + if (!grouped[sid]) grouped[sid] = []; + grouped[sid].push(item); + }); + Object.keys(grouped).forEach((sid) => { + grouped[sid].sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0)); + }); + return grouped; + }, [config.ospfInterfaceTemplates]); + + const ospfHomeServerIds = useMemo(() => { + let ids = Object.keys(homeOspfCandidatesByServer); + if (serverFilter) ids = ids.filter((sid) => String(sid) === String(serverFilter)); + return ids; + }, [homeOspfCandidatesByServer, serverFilter]); + + const handleDropOspfTemplate = (serverId, targetKey) => { + if (!ospfDraggingItem || String(ospfDraggingItem.serverId) !== String(serverId)) return; + const draggedKey = String(ospfDraggingItem.key || ''); + const destinationKey = String(targetKey || ''); + if (!draggedKey || !destinationKey || draggedKey === destinationKey) { + setOspfDraggingItem(null); + return; + } + + setConfig((prev) => { + const current = (prev.ospfInterfaceTemplates || []) + .filter((item) => String(item.serverId || '') === String(serverId)) + .sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0)); + const fromIndex = current.findIndex((item) => String(item.key || '') === draggedKey); + const toIndex = current.findIndex((item) => String(item.key || '') === destinationKey); + if (fromIndex < 0 || toIndex < 0) return prev; + + const reordered = moveInArray(current, fromIndex, toIndex).map((item, idx, arr) => ({ + ...item, + cost: (arr.length - idx) * OSPF_COST_STEP, + })); + + const otherServers = (prev.ospfInterfaceTemplates || []).filter( + (item) => String(item.serverId || '') !== String(serverId) + ); + + return { + ...prev, + ospfInterfaceTemplates: [...otherServers, ...reordered], + }; + }); + + setOspfDraggingItem(null); + }; + // === Группировка по серверам === const gatewaysByServer = useMemo(() => { const grouped = {}; @@ -3665,6 +3869,73 @@ function NetworkConfigManager() { {/* Interfaces Tab */} {activeTab === 'interfaces' && ( <> +
+
+
+

OSPF Interface Template (HOME)

+
+ Только для HOME роутеров. Перетаскивание меняет приоритет: чем выше cost, тем выше приоритет. +
+
+ + {config.ospfInterfaceTemplates?.length || 0} + +
+
+ {ospfHomeServerIds.length === 0 ? ( +
+ Для HOME роутеров пока нет GRE-интерфейсов. Создайте GRE-туннели, и шаблоны появятся автоматически. +
+ ) : ( +
+ {ospfHomeServerIds.map((serverId) => { + const server = getServerInfo(serverId); + const templates = ospfTemplatesByHomeServer[serverId] || []; + return ( +
+
+
+
{server?.dns || server?.ip || serverId}
+
+
+ {templates.length === 0 ? ( +
+ Нет доступных интерфейсов для OSPF шаблонов. +
+ ) : ( + templates.map((template) => ( +
+ setOspfDraggingItem({ serverId, key: template.key }) + } + onDragOver={(e) => e.preventDefault()} + onDrop={() => handleDropOspfTemplate(serverId, template.key)} + onDragEnd={() => setOspfDraggingItem(null)} + style={{ cursor: 'grab' }} + > +
+ :: + {template.interfaceName} +
+ + cost {template.cost} + +
+ )) + )} +
+
+
+ ); + })} +
+ )} +
+
+ {filteredInterfaces.length === 0 ? (