From e631ffab51ec7761ea251972a0ded53a832af45a Mon Sep 17 00:00:00 2001 From: shats Date: Thu, 5 Mar 2026 15:43:02 +0700 Subject: [PATCH] feat(mikrotik-config): add OSPF interface templates management with loading and applying functionality --- backend/routes/mikrotikConfigRoutes.js | 201 ++++++++++++++++++++++++- backend/server.js | 2 + frontend/src/NetworkConfigManager.jsx | 144 +++++++++++++++++- 3 files changed, 338 insertions(+), 9 deletions(-) diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 9c04831..559b8fb 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -15,7 +15,7 @@ const { buildMikrotikRecursiveRoutes, getParentGateway, } = require('../utils/mikrotikInterfaceGenerator'); -const { createRosClient, applyBlock, rosPrint, rosAdd, rosRemove } = require('../services/mikrotikApplyService'); +const { createRosClient, applyBlock, rosPrint, rosAdd, rosSet, rosRemove } = require('../services/mikrotikApplyService'); const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json'; const NETWORK_CONFIG_KEY = 'network-config.json'; @@ -1474,6 +1474,203 @@ async function getAddressLists(req, res) { } } +function normalizeOspfInterfaceName(entry) { + if (!entry || typeof entry !== 'object') return ''; + return String(entry.interfaces ?? entry.interface ?? '').trim(); +} + +function parseOspfCost(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return parsed; +} + +function sameOspfInterface(a, b) { + return String(a || '').trim().toUpperCase() === String(b || '').trim().toUpperCase(); +} + +/** + * GET /api/mikrotik/ospf-interface-templates?serverId= + * Возвращает текущие OSPF interface-template с HOME роутеров. + */ +async function getOspfInterfaceTemplates(req, res) { + try { + const requestedServerId = String(req.query?.serverId || '').trim(); + const servers = await readServersFromS3(); + const homeServers = servers.filter((s) => String(s?.type || '').toLowerCase() === 'home'); + + const targets = requestedServerId + ? homeServers.filter((s) => [s.id, s.ip, s.dns].map((x) => String(x || '')).includes(requestedServerId)) + : homeServers; + + if (requestedServerId && targets.length === 0) { + return sendError(res, 404, 'HOME server not found', 'E_NOT_FOUND'); + } + + const results = []; + const errors = []; + + for (const server of targets) { + const serverId = server.id || server.ip || server.dns; + const creds = getMikrotikCredentials(server); + if (!creds) { + errors.push({ + serverId, + serverLabel: server.dns || server.ip || serverId, + error: 'MikroTik credentials not configured for this server', + }); + continue; + } + + try { + const client = createRosClient(creds); + const raw = await rosPrint(client, 'routing/ospf/interface-template'); + const templates = (Array.isArray(raw) ? raw : []) + .map((item) => { + const interfaceName = normalizeOspfInterfaceName(item); + if (!interfaceName) return null; + return { + id: item['.id'] || null, + interfaceName, + cost: parseOspfCost(item.cost), + area: item.area || '', + networks: item.networks || '', + networkType: item['type'] || item['network-type'] || '', + disabled: String(item.disabled || '').toLowerCase() === 'true', + }; + }) + .filter(Boolean); + + results.push({ + serverId, + serverLabel: server.dns || server.ip || serverId, + templates, + }); + } catch (error) { + errors.push({ + serverId, + serverLabel: server.dns || server.ip || serverId, + error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to read OSPF templates', + }); + } + } + + return res.json({ + ok: errors.length === 0, + results, + errors, + }); + } catch (error) { + return sendError(res, 500, error.message || 'Failed to load OSPF templates', 'E_OSPF_LOAD'); + } +} + +/** + * POST /api/mikrotik/ospf-interface-templates/apply + * Body: { servers: [{ serverId, templates: [{ interfaceName, cost }] }] } + */ +async function applyOspfInterfaceTemplates(req, res) { + try { + const payloadServers = Array.isArray(req.body?.servers) ? req.body.servers : []; + if (payloadServers.length === 0) { + return sendError(res, 400, 'servers array is required', 'E_BAD_REQUEST'); + } + + const servers = await readServersFromS3(); + const results = []; + const errors = []; + const summary = { updated: 0, skipped: 0, missing: 0, invalid: 0 }; + + for (const payload of payloadServers) { + const serverRef = String(payload?.serverId || '').trim(); + const desiredTemplates = Array.isArray(payload?.templates) ? payload.templates : []; + const server = servers.find((s) => + [s?.id, s?.ip, s?.dns].map((x) => String(x || '')).includes(serverRef) + ); + + if (!server || String(server.type || '').toLowerCase() !== 'home') { + errors.push({ serverId: serverRef, error: 'HOME server not found' }); + continue; + } + + const serverId = server.id || server.ip || server.dns; + const creds = getMikrotikCredentials(server); + if (!creds) { + errors.push({ serverId, error: 'MikroTik credentials not configured for this server' }); + continue; + } + + try { + const client = createRosClient(creds); + const raw = await rosPrint(client, 'routing/ospf/interface-template'); + const existing = (Array.isArray(raw) ? raw : []).filter((item) => normalizeOspfInterfaceName(item)); + + let updated = 0; + let skipped = 0; + let missing = 0; + let invalid = 0; + + for (const desired of desiredTemplates) { + const interfaceName = String(desired?.interfaceName || '').trim(); + const desiredCost = parseOspfCost(desired?.cost); + + if (!interfaceName || desiredCost == null) { + invalid += 1; + continue; + } + + const match = existing.find((item) => + sameOspfInterface(normalizeOspfInterfaceName(item), interfaceName) + ); + + if (!match || !match['.id']) { + missing += 1; + continue; + } + + const currentCost = parseOspfCost(match.cost); + if (currentCost === desiredCost) { + skipped += 1; + continue; + } + + await rosSet(client, 'routing/ospf/interface-template', match['.id'], { cost: String(desiredCost) }); + updated += 1; + } + + summary.updated += updated; + summary.skipped += skipped; + summary.missing += missing; + summary.invalid += invalid; + + results.push({ + serverId, + serverLabel: server.dns || server.ip || serverId, + updated, + skipped, + missing, + invalid, + }); + } catch (error) { + errors.push({ + serverId, + serverLabel: server.dns || server.ip || serverId, + error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to apply OSPF templates', + }); + } + } + + return res.json({ + ok: errors.length === 0, + summary, + results, + errors, + }); + } catch (error) { + return sendError(res, 500, error.message || 'Failed to apply OSPF templates', 'E_OSPF_APPLY'); + } +} + const ADDRESS_LIST_PATH = 'ip/firewall/address-list'; const ADDRESS_LIST_REMOVE_CHUNK_SIZE = 100; const ADDRESS_LIST_ADD_CONCURRENCY = 8; @@ -1684,6 +1881,8 @@ module.exports = { speedTestViaTunnel, loadNetworkConfig, getAddressLists, + getOspfInterfaceTemplates, + applyOspfInterfaceTemplates, applyAddressListSummary, uptimeCheck, getUptimeCache, diff --git a/backend/server.js b/backend/server.js index c8064b1..d082ecd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -473,6 +473,8 @@ app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTes app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig); app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript); app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists); +app.get('/api/mikrotik/ospf-interface-templates', mikrotikConfigRoutes.getOspfInterfaceTemplates); +app.post('/api/mikrotik/ospf-interface-templates/apply', writeLimiter, mikrotikConfigRoutes.applyOspfInterfaceTemplates); app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary); // === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) === diff --git a/frontend/src/NetworkConfigManager.jsx b/frontend/src/NetworkConfigManager.jsx index b6d2eee..1eccb70 100644 --- a/frontend/src/NetworkConfigManager.jsx +++ b/frontend/src/NetworkConfigManager.jsx @@ -233,19 +233,56 @@ function normalizeOspfTemplates(rawTemplates = [], candidatesByServer = {}) { serverId: candidate.serverId, tunnelInterfaceId: candidate.tunnelInterfaceId, interfaceName: candidate.interfaceName, + cost: null, }); }); - const withCosts = ordered.map((item, idx) => ({ - ...item, - cost: (ordered.length - idx) * OSPF_COST_STEP, - })); - normalized.push(...withCosts); + 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) }; + }); +} + function moveInArray(arr, fromIndex, toIndex) { if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr; const copy = [...arr]; @@ -353,6 +390,8 @@ function NetworkConfigManager() { const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания const [ospfDraggingItem, setOspfDraggingItem] = useState(null); + const [ospfLoading, setOspfLoading] = useState(false); + const [ospfSaving, setOspfSaving] = useState(false); // === IPSec Passwords Management === const [ipsecPasswords, setIpsecPasswords] = useState([]); @@ -685,6 +724,73 @@ function NetworkConfigManager() { setOspfDraggingItem(null); }; + const handleLoadOspfFromMikrotik = async () => { + setOspfLoading(true); + try { + 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 : []; + + setConfig((prev) => ({ + ...prev, + ospfInterfaceTemplates: mergeOspfCostsFromRouter(prev.ospfInterfaceTemplates || [], results), + })); + + if (results.length > 0) { + notify.success(`OSPF загружен с HOME роутеров: ${results.length}`); + } else { + notify.warning('Не удалось получить OSPF шаблоны ни с одного HOME роутера'); + } + if (errors.length > 0) { + notify.warning(`Ошибки чтения OSPF: ${errors.length} роутер(ов)`); + } + } catch (error) { + console.error('Error loading OSPF templates:', error); + notify.error('Не удалось загрузить OSPF Interface Template с MikroTik'); + } finally { + setOspfLoading(false); + } + }; + + const handleSaveOspfToMikrotik = async () => { + const serverEntries = Object.entries(ospfTemplatesByHomeServer); + if (serverEntries.length === 0) { + notify.warning('Нет OSPF шаблонов для сохранения'); + return; + } + + setOspfSaving(true); + try { + const payload = { + servers: serverEntries.map(([serverId, templates]) => ({ + serverId, + templates: templates.map((item) => ({ + interfaceName: item.interfaceName, + cost: item.cost, + })), + })), + }; + + const response = await api.post('/mikrotik/ospf-interface-templates/apply', payload); + const summary = response.data?.summary || {}; + const errors = Array.isArray(response.data?.errors) ? response.data.errors : []; + notify.success( + `OSPF сохранен: обновлено ${summary.updated || 0}, пропущено ${summary.skipped || 0}, не найдено ${summary.missing || 0}` + ); + if (Number(summary.invalid || 0) > 0) { + notify.warning(`Некорректных записей OSPF: ${summary.invalid}`); + } + if (errors.length > 0) { + notify.warning(`Ошибки применения OSPF: ${errors.length} роутер(ов)`); + } + } catch (error) { + console.error('Error applying OSPF templates:', error); + notify.error('Не удалось сохранить OSPF Interface Template в MikroTik'); + } finally { + setOspfSaving(false); + } + }; + // === Группировка по серверам === const gatewaysByServer = useMemo(() => { const grouped = {}; @@ -3877,9 +3983,31 @@ function NetworkConfigManager() { Только для HOME роутеров. Перетаскивание меняет приоритет: чем выше cost, тем выше приоритет. - - {config.ospfInterfaceTemplates?.length || 0} - +
+ + {config.ospfInterfaceTemplates?.length || 0} + + + +
{ospfHomeServerIds.length === 0 ? (