diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 559b8fb..67cc6a3 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -1489,6 +1489,12 @@ function sameOspfInterface(a, b) { return String(a || '').trim().toUpperCase() === String(b || '').trim().toUpperCase(); } +function sameOspfArea(a, b) { + const left = String(a || '').trim().toUpperCase() || 'BACKBONE'; + const right = String(b || '').trim().toUpperCase() || 'BACKBONE'; + return left === right; +} + /** * GET /api/mikrotik/ospf-interface-templates?serverId= * Возвращает текущие OSPF interface-template с HOME роутеров. @@ -1567,7 +1573,7 @@ async function getOspfInterfaceTemplates(req, res) { /** * POST /api/mikrotik/ospf-interface-templates/apply - * Body: { servers: [{ serverId, templates: [{ interfaceName, cost }] }] } + * Body: { servers: [{ serverId, templates: [{ rosId?, interfaceName, area?, cost }] }] } */ async function applyOspfInterfaceTemplates(req, res) { try { @@ -1611,17 +1617,27 @@ async function applyOspfInterfaceTemplates(req, res) { let invalid = 0; for (const desired of desiredTemplates) { + const desiredRosId = String(desired?.rosId || '').trim(); const interfaceName = String(desired?.interfaceName || '').trim(); + const desiredArea = String(desired?.area || '').trim(); const desiredCost = parseOspfCost(desired?.cost); - if (!interfaceName || desiredCost == null) { + if ((!interfaceName && !desiredRosId) || desiredCost == null) { invalid += 1; continue; } - const match = existing.find((item) => - sameOspfInterface(normalizeOspfInterfaceName(item), interfaceName) - ); + let match = null; + if (desiredRosId) { + match = existing.find((item) => String(item?.['.id'] || '') === desiredRosId); + } + if (!match && interfaceName) { + match = existing.find((item) => { + if (!sameOspfInterface(normalizeOspfInterfaceName(item), interfaceName)) return false; + if (!desiredArea) return true; + return sameOspfArea(item?.area, desiredArea); + }); + } if (!match || !match['.id']) { missing += 1; diff --git a/frontend/src/NetworkConfigManager.jsx b/frontend/src/NetworkConfigManager.jsx index 91fedc3..06fc559 100644 --- a/frontend/src/NetworkConfigManager.jsx +++ b/frontend/src/NetworkConfigManager.jsx @@ -127,160 +127,45 @@ 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 buildOspfTemplatesFromRouterResults(routerResults = [], servers = []) { + const serverById = new Map((servers || []).map((s) => [String(s.id || s.ip || s.dns || ''), s])); + const byComposite = new Map(); -function getHomeOspfCandidates(tunnelInterfaces = [], servers = []) { - const serverByRef = getServerRefMap(servers); - const candidatesByServer = {}; + (routerResults || []).forEach((result) => { + const serverId = String(result?.serverId || '').trim(); + if (!serverId) return; + const server = serverById.get(serverId) || { + id: serverId, + ip: String(result?.serverLabel || serverId), + dns: String(result?.serverLabel || ''), + }; + const identity = getOspfRouterIdentity(server); + const templates = Array.isArray(result?.templates) ? result.templates : []; - (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(); + templates.forEach((tpl) => { + const interfaceName = String(tpl?.interfaceName || '').trim(); + const area = String(tpl?.area || '').trim() || 'backbone'; + const numericCost = Number(tpl?.cost); + const cost = Number.isFinite(numericCost) && numericCost >= 0 ? numericCost : OSPF_COST_STEP; if (!interfaceName) return; - const uniqueKey = `${tunnelId}:${side}:${interfaceName}`; - if (!candidatesByServer[serverId]) candidatesByServer[serverId] = []; - candidatesByServer[serverId].push({ - key: uniqueKey, + const compositeKey = `${identity.key}::${area.toLowerCase()}::${interfaceName.toUpperCase()}`; + if (byComposite.has(compositeKey)) return; + + byComposite.set(compositeKey, { + key: compositeKey, + rosId: tpl?.id || null, serverId, - tunnelInterfaceId: tunnelId, + routerKey: identity.key, + routerLabel: identity.label, 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), + area, + cost, }); }); - - 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, - cost: null, - }); - }); - - const existingCosts = ordered - .map((item) => Number(item?.cost)) - .filter((cost) => Number.isFinite(cost) && cost >= 0); - let nextAutoCost = existingCosts.length > 0 - ? Math.max(OSPF_COST_STEP, Math.min(...existingCosts) - OSPF_COST_STEP) - : Math.max(OSPF_COST_STEP, ordered.length * OSPF_COST_STEP); - - ordered.forEach((item) => { - const numericCost = Number(item?.cost); - if (Number.isFinite(numericCost) && numericCost >= 0) { - normalized.push({ ...item, cost: numericCost }); - return; - } - normalized.push({ ...item, cost: nextAutoCost }); - nextAutoCost = Math.max(OSPF_COST_STEP, nextAutoCost - OSPF_COST_STEP); - }); }); - return normalized; -} - -function mergeOspfCostsFromRouter(currentTemplates = [], routerResults = []) { - const routerCostMap = new Map(); - - (routerResults || []).forEach((serverResult) => { - const serverId = String(serverResult?.serverId || '').trim(); - if (!serverId) return; - const templates = Array.isArray(serverResult?.templates) ? serverResult.templates : []; - templates.forEach((item) => { - const interfaceName = String(item?.interfaceName || '').trim(); - const cost = Number(item?.cost); - if (!interfaceName || !Number.isFinite(cost) || cost < 0) return; - const key = `${serverId}::${interfaceName.toUpperCase()}`; - routerCostMap.set(key, cost); - }); - }); - - return (currentTemplates || []).map((item) => { - const serverId = String(item?.serverId || '').trim(); - const interfaceName = String(item?.interfaceName || '').trim(); - const lookup = `${serverId}::${interfaceName.toUpperCase()}`; - if (!routerCostMap.has(lookup)) return item; - return { ...item, cost: routerCostMap.get(lookup) }; - }); + return Array.from(byComposite.values()); } function moveInArray(arr, fromIndex, toIndex) { @@ -665,23 +550,6 @@ 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 ospfTemplatesByRouter = useMemo(() => { const grouped = {}; (config.ospfInterfaceTemplates || []).forEach((item) => { @@ -703,11 +571,24 @@ function NetworkConfigManager() { }); const groups = Object.values(grouped).map((group) => { - const sorted = [...group.items].sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0)); + const areasMap = {}; + group.items.forEach((item) => { + const area = String(item?.area || '').trim() || 'backbone'; + if (!areasMap[area]) areasMap[area] = []; + areasMap[area].push(item); + }); + + const areas = Object.entries(areasMap) + .map(([area, items]) => ({ + area, + items: [...items].sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0)), + })) + .sort((a, b) => a.area.localeCompare(b.area)); + return { ...group, serverIds: Array.from(group.serverIds), - items: sorted, + areas, }; }); @@ -719,8 +600,9 @@ function NetworkConfigManager() { return groups; }, [config.ospfInterfaceTemplates, serverFilter, servers]); - const handleDropOspfTemplate = (routerKey, targetKey) => { + const handleDropOspfTemplate = (routerKey, area, targetKey) => { if (!ospfDraggingItem || String(ospfDraggingItem.routerKey) !== String(routerKey)) return; + if (String(ospfDraggingItem.area || '') !== String(area || '')) return; const draggedKey = String(ospfDraggingItem.key || ''); const destinationKey = String(targetKey || ''); if (!draggedKey || !destinationKey || draggedKey === destinationKey) { @@ -734,7 +616,8 @@ function NetworkConfigManager() { const sid = String(item.serverId || ''); const server = getServerInfo(sid); const identity = getOspfRouterIdentity(server || { id: sid, ip: sid }); - return identity.key === String(routerKey); + const itemArea = String(item?.area || '').trim() || 'backbone'; + return identity.key === String(routerKey) && itemArea === String(area || 'backbone'); }) .sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0)); const fromIndex = current.findIndex((item) => String(item.key || '') === draggedKey); @@ -750,7 +633,8 @@ function NetworkConfigManager() { const sid = String(item.serverId || ''); const server = getServerInfo(sid); const identity = getOspfRouterIdentity(server || { id: sid, ip: sid }); - return identity.key !== String(routerKey); + const itemArea = String(item?.area || '').trim() || 'backbone'; + return identity.key !== String(routerKey) || itemArea !== String(area || 'backbone'); }); return { @@ -768,10 +652,11 @@ function NetworkConfigManager() { const response = await api.get('/mikrotik/ospf-interface-templates'); const results = Array.isArray(response.data?.results) ? response.data.results : []; const errors = Array.isArray(response.data?.errors) ? response.data.errors : []; + const loadedTemplates = buildOspfTemplatesFromRouterResults(results, servers); setConfig((prev) => ({ ...prev, - ospfInterfaceTemplates: mergeOspfCostsFromRouter(prev.ospfInterfaceTemplates || [], results), + ospfInterfaceTemplates: loadedTemplates, })); if (!silent && results.length > 0) { @@ -803,15 +688,21 @@ function NetworkConfigManager() { const payload = { servers: ospfTemplatesByRouter.map((group) => { const dedup = new Map(); - group.items.forEach((item) => { - const key = String(item.interfaceName || '').trim().toUpperCase(); - if (!key) return; - if (!dedup.has(key)) { - dedup.set(key, { - interfaceName: item.interfaceName, - cost: item.cost, - }); - } + (group.areas || []).forEach((areaGroup) => { + (areaGroup.items || []).forEach((item) => { + const interfaceName = String(item.interfaceName || '').trim(); + const area = String(item.area || '').trim() || 'backbone'; + if (!interfaceName) return; + const key = `${area.toUpperCase()}::${interfaceName.toUpperCase()}`; + if (!dedup.has(key)) { + dedup.set(key, { + rosId: item.rosId || null, + interfaceName, + area, + cost: item.cost, + }); + } + }); }); return { serverId: group.representativeServerId, @@ -842,10 +733,11 @@ function NetworkConfigManager() { useEffect(() => { if (activeTab !== 'interfaces') return; + if (!Array.isArray(servers) || servers.length === 0) return; if (ospfAutoLoadedRef.current) return; ospfAutoLoadedRef.current = true; handleLoadOspfFromMikrotik({ silent: true }); - }, [activeTab]); + }, [activeTab, servers]); // === Группировка по серверам === const gatewaysByServer = useMemo(() => { @@ -4036,7 +3928,7 @@ function NetworkConfigManager() {

OSPF Interface Template (HOME)

- Только для HOME роутеров. Перетаскивание меняет приоритет: чем выше cost, тем выше приоритет. + Только для HOME роутеров. Источник данных — MikroTik, группировка по Area.
@@ -4068,44 +3960,59 @@ function NetworkConfigManager() {
{ospfTemplatesByRouter.length === 0 ? (
- Для HOME роутеров пока нет GRE-интерфейсов. Создайте GRE-туннели, и шаблоны появятся автоматически. + OSPF Interface Template не найдены на HOME роутерах.
) : (
{ospfTemplatesByRouter.map((group) => { - const templates = group.items || []; + const areas = group.areas || []; return (
{group.routerLabel}
-
- {templates.length === 0 ? ( +
+ {areas.length === 0 ? (
Нет доступных интерфейсов для OSPF шаблонов.
) : ( - templates.map((template) => ( -
- setOspfDraggingItem({ routerKey: group.routerKey, key: template.key }) - } - onDragOver={(e) => e.preventDefault()} - onDrop={() => handleDropOspfTemplate(group.routerKey, template.key)} - onDragEnd={() => setOspfDraggingItem(null)} - style={{ cursor: 'grab' }} - > -
- :: - {template.interfaceName} + areas.map((areaGroup) => ( +
+
+ Area: {areaGroup.area} +
+
+ {areaGroup.items.map((template) => ( +
+ setOspfDraggingItem({ + routerKey: group.routerKey, + area: areaGroup.area, + key: template.key, + }) + } + onDragOver={(e) => e.preventDefault()} + onDrop={() => + handleDropOspfTemplate(group.routerKey, areaGroup.area, template.key) + } + onDragEnd={() => setOspfDraggingItem(null)} + style={{ cursor: 'grab' }} + > +
+ :: + {template.interfaceName} +
+ + cost {template.cost} + +
+ ))}
- - cost {template.cost} -
)) )}