From 661b42f7769d6cc73ac7861fa55612f8808d4a57 Mon Sep 17 00:00:00 2001 From: shats Date: Wed, 4 Mar 2026 23:09:23 +0700 Subject: [PATCH] feat(route-optimizer): integrate AI route optimizer functionality into the application with routing endpoint and UI components --- backend/routes/routeOptimizerRoutes.js | 323 +++++++++++++++++++++++++ backend/server.js | 4 + frontend/src/App.jsx | 10 +- frontend/src/RouteOptimizerPage.jsx | 168 +++++++++++++ 4 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 backend/routes/routeOptimizerRoutes.js create mode 100644 frontend/src/RouteOptimizerPage.jsx diff --git a/backend/routes/routeOptimizerRoutes.js b/backend/routes/routeOptimizerRoutes.js new file mode 100644 index 0000000..fb9548f --- /dev/null +++ b/backend/routes/routeOptimizerRoutes.js @@ -0,0 +1,323 @@ +/** + * Rule-based "local AI" optimizer for route selection. + * Returns best paths and probability of optimality for all candidates. + */ + +const { sendError } = require('../middleware/errorHandler'); +const { readS3TextObject } = require('../services/s3Service'); +const { readServersFromS3 } = require('./serversRoutes'); + +const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; +const NETWORK_CONFIG_KEY = 'network-config.json'; + +function asObject(v) { + return v && typeof v === 'object' ? v : {}; +} + +function toNumber(v) { + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +function clamp(n, min, max) { + return Math.max(min, Math.min(max, n)); +} + +function makeServerKey(server) { + if (!server || typeof server !== 'object') return ''; + return String(server.id || server.dns || server.ip || '').trim(); +} + +function resolveServerRef(ref, byRef) { + const key = String(ref || '').trim(); + if (!key) return null; + return byRef.get(key) || null; +} + +function pairKey(a, b) { + return [String(a || ''), String(b || '')].sort().join(':'); +} + +function pairProbability(items, scoreGetter) { + if (!Array.isArray(items) || items.length === 0) return []; + const values = items.map((it) => Number(scoreGetter(it) || 0)); + const max = Math.max(...values); + const exps = values.map((v) => Math.exp((v - max) * 5)); + const sum = exps.reduce((acc, x) => acc + x, 0) || 1; + return exps.map((x) => (x / sum) * 100); +} + +function latencyScore(pingMs) { + if (typeof pingMs !== 'number') return 0.2; + const normalized = 1 / (1 + pingMs / 35); + return clamp(normalized, 0, 1); +} + +function bandwidthScore(speedMbps) { + if (typeof speedMbps !== 'number' || speedMbps <= 0) return 0.15; + // log-scale to avoid dominance by very high channels + const normalized = Math.log10(1 + speedMbps) / Math.log10(1001); + return clamp(normalized, 0, 1); +} + +function freshnessScore(cacheUpdatedAt) { + if (typeof cacheUpdatedAt !== 'number') return 0.4; + const ageMs = Math.max(0, Date.now() - cacheUpdatedAt); + if (ageMs <= 2 * 60 * 1000) return 1; + if (ageMs <= 10 * 60 * 1000) return 0.8; + if (ageMs <= 30 * 60 * 1000) return 0.6; + return 0.35; +} + +function buildSegmentScore({ pingMs, speedMbps, cacheUpdatedAt }) { + const l = latencyScore(pingMs); + const b = bandwidthScore(speedMbps); + const f = freshnessScore(cacheUpdatedAt); + const metricPresence = (typeof pingMs === 'number' ? 1 : 0) + (typeof speedMbps === 'number' ? 1 : 0); + const confidence = metricPresence === 2 ? 1 : metricPresence === 1 ? 0.65 : 0.35; + const score = l * 0.55 + b * 0.35 + f * 0.1; + return { score: clamp(score, 0, 1), confidence }; +} + +function compactServer(server) { + return { + id: server?.id || null, + ip: server?.ip || null, + dns: server?.dns || null, + type: server?.type || null, + label: server?.dns || server?.ip || server?.id || 'unknown', + }; +} + +async function loadNetworkMapCache() { + try { + const raw = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null); + if (!raw?.body) return { pingMap: {}, speedMap: {}, updatedAt: null }; + const parsed = JSON.parse(raw.body || '{}'); + return { + pingMap: asObject(parsed.pingMap), + speedMap: asObject(parsed.speedMap), + updatedAt: toNumber(parsed.updatedAt), + }; + } catch (_) { + return { pingMap: {}, speedMap: {}, updatedAt: null }; + } +} + +async function loadNetworkConfig() { + try { + const raw = await readS3TextObject(NETWORK_CONFIG_KEY).catch(() => null); + if (!raw?.body) return {}; + const parsed = JSON.parse(raw.body || '{}'); + return asObject(parsed); + } catch (_) { + return {}; + } +} + +function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap, cacheUpdatedAt }) { + const byRef = new Map(); + servers.forEach((s) => { + const refs = [s.id, s.ip, s.dns].filter(Boolean).map((v) => String(v)); + refs.forEach((ref) => byRef.set(ref, s)); + }); + + const homeToJh = []; + const jhToExit = []; + + for (const iface of tunnelInterfaces) { + if (!iface || !iface.serverId || !iface.serverId2) continue; + const s1 = resolveServerRef(iface.serverId, byRef); + const s2 = resolveServerRef(iface.serverId2, byRef); + if (!s1 || !s2) continue; + + const t1 = String(s1.type || '').toLowerCase(); + const t2 = String(s2.type || '').toLowerCase(); + const k1 = makeServerKey(s1); + const k2 = makeServerKey(s2); + const pkey = pairKey(s1.ip, s2.ip); + const skey = pairKey(k1, k2); + const pingMs = toNumber(pingMap[pkey]); + const speedEntry = asObject(speedMap[skey]); + const downBps = toNumber(speedEntry.tcpDownloadBps); + const upBps = toNumber(speedEntry.tcpUploadBps); + const speedMbps = downBps != null || upBps != null + ? Math.max(downBps || 0, upBps || 0) / 1e6 + : null; + const scoring = buildSegmentScore({ pingMs, speedMbps, cacheUpdatedAt }); + + const base = { + interfaceName: iface.name || null, + pingMs, + speedMbps: speedMbps != null ? Number(speedMbps.toFixed(2)) : null, + speedDownloadMbps: downBps != null ? Number((downBps / 1e6).toFixed(2)) : null, + speedUploadMbps: upBps != null ? Number((upBps / 1e6).toFixed(2)) : null, + score: Number(scoring.score.toFixed(4)), + confidence: Number(scoring.confidence.toFixed(4)), + }; + + if ((t1 === 'home' && t2 === 'jumphost') || (t1 === 'jumphost' && t2 === 'home')) { + const home = t1 === 'home' ? s1 : s2; + const jumphost = t1 === 'jumphost' ? s1 : s2; + homeToJh.push({ + id: `${makeServerKey(home)}->${makeServerKey(jumphost)}::${base.interfaceName || 'iface'}`, + homeKey: makeServerKey(home), + jumphostKey: makeServerKey(jumphost), + home: compactServer(home), + jumphost: compactServer(jumphost), + ...base, + }); + continue; + } + + if ((t1 === 'jumphost' && t2 === 'exit') || (t1 === 'exit' && t2 === 'jumphost')) { + const jumphost = t1 === 'jumphost' ? s1 : s2; + const exit = t1 === 'exit' ? s1 : s2; + jhToExit.push({ + id: `${makeServerKey(jumphost)}->${makeServerKey(exit)}::${base.interfaceName || 'iface'}`, + jumphostKey: makeServerKey(jumphost), + exitKey: makeServerKey(exit), + jumphost: compactServer(jumphost), + exit: compactServer(exit), + ...base, + }); + } + } + + return { homeToJh, jhToExit }; +} + +function enrichProbabilities(candidates, scoreField = 'score') { + if (!Array.isArray(candidates) || candidates.length === 0) return []; + const probs = pairProbability(candidates, (c) => c[scoreField]); + return candidates.map((c, i) => ({ + ...c, + probabilityOptimal: Number(probs[i].toFixed(2)), + })); +} + +function groupBy(items, keyGetter) { + const m = new Map(); + for (const item of items) { + const key = keyGetter(item); + if (!m.has(key)) m.set(key, []); + m.get(key).push(item); + } + return m; +} + +async function getRouteOptimizer(req, res) { + try { + const [servers, networkConfig, networkMapCache] = await Promise.all([ + readServersFromS3(), + loadNetworkConfig(), + loadNetworkMapCache(), + ]); + + const tunnelInterfaces = Array.isArray(networkConfig.tunnelInterfaces) + ? networkConfig.tunnelInterfaces + : []; + + const { homeToJh, jhToExit } = buildInterfaceCandidates({ + servers: Array.isArray(servers) ? servers : [], + tunnelInterfaces, + pingMap: networkMapCache.pingMap, + speedMap: networkMapCache.speedMap, + cacheUpdatedAt: networkMapCache.updatedAt, + }); + + const homeGroups = groupBy(homeToJh, (x) => x.homeKey); + const exitsByJh = groupBy(jhToExit, (x) => x.jumphostKey); + + const homes = []; + + for (const [homeKey, hjListRaw] of homeGroups.entries()) { + const hjList = enrichProbabilities(hjListRaw.sort((a, b) => b.score - a.score)); + const bestHomeToJumphost = hjList[0] || null; + + const fullRoutesRaw = []; + for (const hj of hjList) { + const exitCandidates = exitsByJh.get(hj.jumphostKey) || []; + for (const jhExit of exitCandidates) { + const combinedScore = clamp(hj.score * 0.45 + jhExit.score * 0.55, 0, 1); + fullRoutesRaw.push({ + id: `${hj.id}>>>${jhExit.id}`, + home: hj.home, + jumphost: hj.jumphost, + exit: jhExit.exit, + homeToJumphost: { + interfaceName: hj.interfaceName, + pingMs: hj.pingMs, + speedMbps: hj.speedMbps, + score: hj.score, + confidence: hj.confidence, + }, + jumphostToExit: { + interfaceName: jhExit.interfaceName, + pingMs: jhExit.pingMs, + speedMbps: jhExit.speedMbps, + score: jhExit.score, + confidence: jhExit.confidence, + }, + score: Number(combinedScore.toFixed(4)), + confidence: Number(((hj.confidence + jhExit.confidence) / 2).toFixed(4)), + }); + } + } + + const fullRoutes = enrichProbabilities(fullRoutesRaw.sort((a, b) => b.score - a.score)); + const bestFullRoute = fullRoutes[0] || null; + + homes.push({ + home: bestHomeToJumphost?.home || null, + bestHomeToJumphost, + bestFullRoute, + homeToJumphostCandidates: hjList, + fullRouteCandidates: fullRoutes, + }); + } + + // Also expose jumphost->exit view for transparency + const jumphostSummaries = []; + for (const [jumphostKey, exitListRaw] of exitsByJh.entries()) { + const candidates = enrichProbabilities(exitListRaw.sort((a, b) => b.score - a.score)); + jumphostSummaries.push({ + jumphost: candidates[0]?.jumphost || null, + bestCandidate: candidates[0] || null, + candidates, + }); + } + + homes.sort((a, b) => { + const pa = a.bestFullRoute?.probabilityOptimal ?? 0; + const pb = b.bestFullRoute?.probabilityOptimal ?? 0; + return pb - pa; + }); + + return res.json({ + ok: true, + generatedAt: Date.now(), + metricsUpdatedAt: networkMapCache.updatedAt || null, + homes, + jumphostToExitByJumphost: jumphostSummaries, + totals: { + homes: homes.length, + homeToJumphostCandidates: homeToJh.length, + jumphostToExitCandidates: jhToExit.length, + }, + }); + } catch (error) { + console.error('[route-optimizer] getRouteOptimizer:', error); + return sendError( + res, + 500, + error?.message || 'Ошибка расчёта оптимального маршрута', + 'E_ROUTE_OPTIMIZER' + ); + } +} + +module.exports = { + getRouteOptimizer, +}; + diff --git a/backend/server.js b/backend/server.js index 3431fa3..c8064b1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -32,6 +32,7 @@ const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes'); const trafficRoutes = require('./routes/trafficRoutes'); const resourceStatsRoutes = require('./routes/resourceStatsRoutes'); const alertsRoutes = require('./routes/alertsRoutes'); +const routeOptimizerRoutes = require('./routes/routeOptimizerRoutes'); const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler'); const { initNetworkMapScheduler } = require('./services/networkMapScheduler'); const { initPingServicesScheduler } = require('./services/pingServicesScheduler'); @@ -497,6 +498,9 @@ app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBack // === NETWORK MAP CACHE (для быстрой загрузки карты сети) === app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache); +// === LOCAL AI ROUTE OPTIMIZER (rule-based) === +app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer); + // === SCHEDULER (карта сети: пинг и скорость по расписанию) === app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings); app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9698bb9..30b3f24 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -27,7 +27,8 @@ import { IconChartBar, IconClock, IconClockPlay, - IconShield + IconShield, + IconRoute2 } from '@tabler/icons-react'; import ServerManager from './ServerManager'; import FilterManager from './FilterManager'; @@ -51,6 +52,7 @@ import PingServicesManager from './PingServicesManager.jsx'; import FirewallPage from './FirewallPage.jsx'; import SettingsPage from './SettingsPage.jsx'; import ResourceStatsPage from './ResourceStatsPage.jsx'; +import RouteOptimizerPage from './RouteOptimizerPage.jsx'; import './App.css'; import { NotifyProvider } from './components/NotifyProvider.jsx'; import ToastContainer from './components/ToastContainer.jsx'; @@ -71,6 +73,7 @@ function LanguageProvider({ children }) { ru: { home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты', dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', + routeOptimizer: 'Оптимизация маршрутов ИИ', communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL', easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', light: 'Светлая', dark: 'Тёмная', @@ -79,6 +82,7 @@ function LanguageProvider({ children }) { en: { home: 'Home', data: 'Data', management: 'Management', tools: 'Tools', dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs', + routeOptimizer: 'AI Route Optimizer', communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs', easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall', light: 'Light', dark: 'Dark', @@ -165,7 +169,6 @@ function MainLayout() { useEffect(() => { if (layout !== LAYOUT_FLUID) return; const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID); - const toggler = () => sidebarTogglerRef.current?.click?.(); const onShow = () => { if (window.innerWidth < MOBILE_BREAKPOINT) { setSidebarBackdrop(true); @@ -252,6 +255,7 @@ function MainLayout() { { id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }, { id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar }, { id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork }, + { id: 'route-optimizer', title: t('routeOptimizer'), path: '/route-optimizer', icon: IconRoute2 }, { id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock } ] }, @@ -449,6 +453,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> @@ -625,6 +630,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/RouteOptimizerPage.jsx b/frontend/src/RouteOptimizerPage.jsx new file mode 100644 index 0000000..ebc21a1 --- /dev/null +++ b/frontend/src/RouteOptimizerPage.jsx @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + IconRoute2, + IconRefresh, + IconBrain, + IconAlertCircle, +} from '@tabler/icons-react'; +import api from './lib/api.js'; +import PageHeader from './components/PageHeader.jsx'; + +function MetricBadge({ label, value, tone = 'secondary' }) { + return ( + + {label}: {value} + + ); +} + +export default function RouteOptimizerPage() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [data, setData] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await api.get('/route-optimizer', { timeout: 30000 }); + setData(res?.data || null); + } catch (e) { + console.error('[RouteOptimizerPage] load failed:', e); + setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить маршруты'); + setData(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + const id = setInterval(load, 30000); + return () => clearInterval(id); + }, [load]); + + const homes = Array.isArray(data?.homes) ? data.homes : []; + + return ( +
+ } + meta="Rule-based выбор маршрутов: Home → Jumphost → Exit" + actions={( + + )} + /> + + {error && ( +
+ + {error} +
+ )} + + {loading && !data && ( +
Расчёт маршрутов...
+ )} + + {!loading && !error && homes.length === 0 && ( +
+ Нет доступных маршрутов. Проверьте типы серверов (`home`, `jumphost`, `exit`) и `network-config.tunnelInterfaces`. +
+ )} + +
+ {homes.map((entry) => { + const best = entry.bestFullRoute; + const home = entry.home || {}; + const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : []; + return ( +
+
+
+

+ + {home.label || home.dns || home.ip || 'Home'} +

+ {best ? ( + + ) : ( + + )} +
+
+ {best ? ( +
+
Оптимальный маршрут
+
+ {best.home?.label || 'home'} + + {best.jumphost?.label || 'jumphost'} + + {best.exit?.label || 'exit'} + + +
+
+ ) : null} + +
+ + + + + + + + + + + + {routes.length === 0 ? ( + + + + ) : routes.map((r) => ( + + + + + + + + ))} + +
МаршрутВероятностьHome→JumphostJumphost→ExitScore
+ Полные маршруты до exit-ноды не найдены. +
+ {r.home?.label} + + {r.jumphost?.label} + + {r.exit?.label} + + {r.probabilityOptimal}% + + ping: {r.homeToJumphost?.pingMs ?? '—'} ms, speed: {r.homeToJumphost?.speedMbps ?? '—'} Mbps + + ping: {r.jumphostToExit?.pingMs ?? '—'} ms, speed: {r.jumphostToExit?.speedMbps ?? '—'} Mbps + {r.score}
+
+
+
+
+ ); + })} +
+
+ ); +} +