From 7b7ef1ab45fca4aceb6468c7ee039ad5bcb99df7 Mon Sep 17 00:00:00 2001 From: shats Date: Wed, 4 Mar 2026 23:22:09 +0700 Subject: [PATCH] feat(route-optimizer): enhance AI settings management and UI integration for route optimization --- backend/routes/routeOptimizerRoutes.js | 152 +++++++++--- frontend/src/RouteOptimizerPage.jsx | 21 +- frontend/src/SettingsPage.jsx | 311 ++++++++++++++++++++++++- 3 files changed, 450 insertions(+), 34 deletions(-) diff --git a/backend/routes/routeOptimizerRoutes.js b/backend/routes/routeOptimizerRoutes.js index 462b602..8313108 100644 --- a/backend/routes/routeOptimizerRoutes.js +++ b/backend/routes/routeOptimizerRoutes.js @@ -9,6 +9,23 @@ const { readServersFromS3 } = require('./serversRoutes'); const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; const NETWORK_CONFIG_KEY = 'network-config.json'; +const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; + +const DEFAULT_AI_SETTINGS = { + latencyWeight: 0.55, + bandwidthWeight: 0.35, + freshnessWeight: 0.1, + combineHomeToJumphostWeight: 0.45, + combineJumphostToExitWeight: 0.55, + probabilityScale: 5, + minProbabilityGainForSwitch: 10, + noPingScore: 0.2, + noSpeedScore: 0.15, + staleScore: 0.35, + freshnessExcellentSeconds: 120, + freshnessGoodSeconds: 600, + freshnessFairSeconds: 1800, +}; function asObject(v) { return v && typeof v === 'object' ? v : {}; @@ -23,6 +40,11 @@ function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } +function positiveNumber(v, fallback) { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + function makeServerKey(server) { if (!server || typeof server !== 'object') return ''; return String(server.id || server.dns || server.ip || '').trim(); @@ -47,35 +69,41 @@ function pairProbability(items, scoreGetter) { return exps.map((x) => (x / sum) * 100); } -function latencyScore(pingMs) { - if (typeof pingMs !== 'number') return 0.2; +function latencyScore(pingMs, aiSettings) { + if (typeof pingMs !== 'number') return aiSettings.noPingScore; const normalized = 1 / (1 + pingMs / 35); return clamp(normalized, 0, 1); } -function bandwidthScore(speedMbps) { - if (typeof speedMbps !== 'number' || speedMbps <= 0) return 0.15; +function bandwidthScore(speedMbps, aiSettings) { + if (typeof speedMbps !== 'number' || speedMbps <= 0) return aiSettings.noSpeedScore; // 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; +function freshnessScore(cacheUpdatedAt, aiSettings) { + if (typeof cacheUpdatedAt !== 'number') return aiSettings.staleScore; 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; + const excellentMs = aiSettings.freshnessExcellentSeconds * 1000; + const goodMs = aiSettings.freshnessGoodSeconds * 1000; + const fairMs = aiSettings.freshnessFairSeconds * 1000; + if (ageMs <= excellentMs) return 1; + if (ageMs <= goodMs) return 0.8; + if (ageMs <= fairMs) return 0.6; + return aiSettings.staleScore; } -function buildSegmentScore({ pingMs, speedMbps, cacheUpdatedAt }) { - const l = latencyScore(pingMs); - const b = bandwidthScore(speedMbps); - const f = freshnessScore(cacheUpdatedAt); +function buildSegmentScore({ pingMs, speedMbps, cacheUpdatedAt, aiSettings }) { + const l = latencyScore(pingMs, aiSettings); + const b = bandwidthScore(speedMbps, aiSettings); + const f = freshnessScore(cacheUpdatedAt, aiSettings); 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; + const score = + l * aiSettings.latencyWeight + + b * aiSettings.bandwidthWeight + + f * aiSettings.freshnessWeight; return { score: clamp(score, 0, 1), confidence }; } @@ -168,7 +196,69 @@ async function loadNetworkConfig() { } } -function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap, cacheUpdatedAt }) { +async function loadAiSettings() { + try { + const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => null); + const parsed = raw?.body ? JSON.parse(raw.body || '{}') : {}; + const src = asObject(parsed?.aiRouteOptimizer); + const latencyWeight = positiveNumber(src.latencyWeight, DEFAULT_AI_SETTINGS.latencyWeight); + const bandwidthWeight = positiveNumber(src.bandwidthWeight, DEFAULT_AI_SETTINGS.bandwidthWeight); + const freshnessWeight = positiveNumber(src.freshnessWeight, DEFAULT_AI_SETTINGS.freshnessWeight); + const sum = latencyWeight + bandwidthWeight + freshnessWeight || 1; + const hjW = positiveNumber( + src.combineHomeToJumphostWeight, + DEFAULT_AI_SETTINGS.combineHomeToJumphostWeight + ); + const jeW = positiveNumber( + src.combineJumphostToExitWeight, + DEFAULT_AI_SETTINGS.combineJumphostToExitWeight + ); + const sum2 = hjW + jeW || 1; + + return { + latencyWeight: latencyWeight / sum, + bandwidthWeight: bandwidthWeight / sum, + freshnessWeight: freshnessWeight / sum, + combineHomeToJumphostWeight: hjW / sum2, + combineJumphostToExitWeight: jeW / sum2, + probabilityScale: clamp( + positiveNumber(src.probabilityScale, DEFAULT_AI_SETTINGS.probabilityScale), + 0.5, + 20 + ), + minProbabilityGainForSwitch: clamp( + positiveNumber( + src.minProbabilityGainForSwitch, + DEFAULT_AI_SETTINGS.minProbabilityGainForSwitch + ), + 0, + 100 + ), + noPingScore: clamp(positiveNumber(src.noPingScore, DEFAULT_AI_SETTINGS.noPingScore), 0, 1), + noSpeedScore: clamp(positiveNumber(src.noSpeedScore, DEFAULT_AI_SETTINGS.noSpeedScore), 0, 1), + staleScore: clamp(positiveNumber(src.staleScore, DEFAULT_AI_SETTINGS.staleScore), 0, 1), + freshnessExcellentSeconds: clamp( + positiveNumber(src.freshnessExcellentSeconds, DEFAULT_AI_SETTINGS.freshnessExcellentSeconds), + 10, + 86400 + ), + freshnessGoodSeconds: clamp( + positiveNumber(src.freshnessGoodSeconds, DEFAULT_AI_SETTINGS.freshnessGoodSeconds), + 10, + 86400 + ), + freshnessFairSeconds: clamp( + positiveNumber(src.freshnessFairSeconds, DEFAULT_AI_SETTINGS.freshnessFairSeconds), + 10, + 86400 + ), + }; + } catch (_) { + return { ...DEFAULT_AI_SETTINGS }; + } +} + +function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap, cacheUpdatedAt, aiSettings }) { const byRef = new Map(); servers.forEach((s) => { const refs = [s.id, s.ip, s.dns].filter(Boolean).map((v) => String(v)); @@ -197,7 +287,7 @@ function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap const speedMbps = downBps != null || upBps != null ? Math.max(downBps || 0, upBps || 0) / 1e6 : null; - const scoring = buildSegmentScore({ pingMs, speedMbps, cacheUpdatedAt }); + const scoring = buildSegmentScore({ pingMs, speedMbps, cacheUpdatedAt, aiSettings }); const base = { interfaceName: iface.name || null, @@ -244,9 +334,9 @@ function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap return { homeToJh, jhToExit }; } -function enrichProbabilities(candidates, scoreField = 'score') { +function enrichProbabilities(candidates, scoreField = 'score', aiSettings = DEFAULT_AI_SETTINGS) { if (!Array.isArray(candidates) || candidates.length === 0) return []; - const probs = pairProbability(candidates, (c) => c[scoreField]); + const probs = pairProbability(candidates, (c) => c[scoreField] * aiSettings.probabilityScale); return candidates.map((c, i) => ({ ...c, probabilityOptimal: Number(probs[i].toFixed(2)), @@ -267,11 +357,12 @@ function buildCommunityOptimization({ exitsByJh, serverFiltersByServerId, communitiesIndex, + aiSettings, }) { const jumphostByCommunity = []; for (const [jumphostKey, rawCandidates] of exitsByJh.entries()) { - const candidates = enrichProbabilities([...rawCandidates].sort((a, b) => b.score - a.score)); + const candidates = enrichProbabilities([...rawCandidates].sort((a, b) => b.score - a.score), 'score', aiSettings); const gatewayBestMap = new Map(); for (const c of candidates) { const gw = String(c.gatewayIpForJumphost || '').trim(); @@ -300,7 +391,7 @@ function buildCommunityOptimization({ recommendedCandidate && currentCandidate && recommendedCandidate.gatewayIpForJumphost !== currentCandidate.gatewayIpForJumphost && - (recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >= 10 + (recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >= aiSettings.minProbabilityGainForSwitch ); return { @@ -344,12 +435,13 @@ function buildCommunityOptimization({ async function getRouteOptimizer(req, res) { try { - const [servers, networkConfig, networkMapCache, communitiesIndex, serverFiltersByServerId] = await Promise.all([ + const [servers, networkConfig, networkMapCache, communitiesIndex, serverFiltersByServerId, aiSettings] = await Promise.all([ readServersFromS3(), loadNetworkConfig(), loadNetworkMapCache(), loadCommunitiesIndex(), loadServerFiltersByServerId(), + loadAiSettings(), ]); const tunnelInterfaces = Array.isArray(networkConfig.tunnelInterfaces) @@ -362,6 +454,7 @@ async function getRouteOptimizer(req, res) { pingMap: networkMapCache.pingMap, speedMap: networkMapCache.speedMap, cacheUpdatedAt: networkMapCache.updatedAt, + aiSettings, }); const homeGroups = groupBy(homeToJh, (x) => x.homeKey); @@ -370,14 +463,19 @@ async function getRouteOptimizer(req, res) { const homes = []; for (const [homeKey, hjListRaw] of homeGroups.entries()) { - const hjList = enrichProbabilities(hjListRaw.sort((a, b) => b.score - a.score)); + const hjList = enrichProbabilities(hjListRaw.sort((a, b) => b.score - a.score), 'score', aiSettings); 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); + const combinedScore = clamp( + hj.score * aiSettings.combineHomeToJumphostWeight + + jhExit.score * aiSettings.combineJumphostToExitWeight, + 0, + 1 + ); fullRoutesRaw.push({ id: `${hj.id}>>>${jhExit.id}`, home: hj.home, @@ -403,7 +501,7 @@ async function getRouteOptimizer(req, res) { } } - const fullRoutes = enrichProbabilities(fullRoutesRaw.sort((a, b) => b.score - a.score)); + const fullRoutes = enrichProbabilities(fullRoutesRaw.sort((a, b) => b.score - a.score), 'score', aiSettings); const bestFullRoute = fullRoutes[0] || null; homes.push({ @@ -418,7 +516,7 @@ async function getRouteOptimizer(req, res) { // 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)); + const candidates = enrichProbabilities(exitListRaw.sort((a, b) => b.score - a.score), 'score', aiSettings); jumphostSummaries.push({ jumphost: candidates[0]?.jumphost || null, bestCandidate: candidates[0] || null, @@ -430,6 +528,7 @@ async function getRouteOptimizer(req, res) { exitsByJh, serverFiltersByServerId, communitiesIndex, + aiSettings, }); homes.sort((a, b) => { @@ -442,6 +541,7 @@ async function getRouteOptimizer(req, res) { ok: true, generatedAt: Date.now(), metricsUpdatedAt: networkMapCache.updatedAt || null, + aiSettingsUsed: aiSettings, homes, jumphostToExitByJumphost: jumphostSummaries, communityOptimizationByJumphost: communityOptimization, diff --git a/frontend/src/RouteOptimizerPage.jsx b/frontend/src/RouteOptimizerPage.jsx index bc71f8e..24543e6 100644 --- a/frontend/src/RouteOptimizerPage.jsx +++ b/frontend/src/RouteOptimizerPage.jsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { IconRoute2, IconRefresh, @@ -54,10 +55,15 @@ export default function RouteOptimizerPage() { icon={} meta="Rule-based выбор маршрутов: Home → Jumphost → Exit" actions={( - +
+ + Настройки AI + + +
)} /> @@ -169,12 +175,13 @@ export default function RouteOptimizerPage() {

Оптимизация по community и filters

- Рекомендации строятся на основе ваших `server-filters` (`community -> gateway`) и текущих метрик канала - `jumphost -> exit`. + Рекомендации строятся на основе ваших server-filters (связки + community -> gateway) и текущих метрик канала + jumphost -> exit.
{communityOptimization.length === 0 ? ( -
Нет данных по `server-filters` для jumphost.
+
Нет данных по server-filters для jumphost.
) : (
{communityOptimization.map((item) => { diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx index cdedbca..ccfdd69 100644 --- a/frontend/src/SettingsPage.jsx +++ b/frontend/src/SettingsPage.jsx @@ -18,6 +18,7 @@ import { IconCpu, IconDeviceDesktop, IconDatabase, + IconBrain, } from '@tabler/icons-react'; import FormField from './components/FormField'; import ErrorAlert from './components/ErrorAlert'; @@ -55,6 +56,7 @@ const SIDEBAR_GROUPS = [ title: 'Аналитика', items: [ { id: 'traffic-interfaces', title: 'Настройка Аналитики', icon: IconChartBar }, + { id: 'route-ai', title: 'AI оптимизация маршрутов', icon: IconBrain }, ], }, { @@ -120,6 +122,19 @@ export default function SettingsPage() { const [serversList, setServersList] = useState([]); const [tunnelConnections, setTunnelConnections] = useState([]); const [tunnelThresholds, setTunnelThresholds] = useState([]); + const [aiLatencyWeight, setAiLatencyWeight] = useState('0.55'); + const [aiBandwidthWeight, setAiBandwidthWeight] = useState('0.35'); + const [aiFreshnessWeight, setAiFreshnessWeight] = useState('0.10'); + const [aiHomeToJhWeight, setAiHomeToJhWeight] = useState('0.45'); + const [aiJhToExitWeight, setAiJhToExitWeight] = useState('0.55'); + const [aiProbabilityScale, setAiProbabilityScale] = useState('5'); + const [aiMinProbabilityGainForSwitch, setAiMinProbabilityGainForSwitch] = useState('10'); + const [aiNoPingScore, setAiNoPingScore] = useState('0.2'); + const [aiNoSpeedScore, setAiNoSpeedScore] = useState('0.15'); + const [aiStaleScore, setAiStaleScore] = useState('0.35'); + const [aiFreshnessExcellentSeconds, setAiFreshnessExcellentSeconds] = useState('120'); + const [aiFreshnessGoodSeconds, setAiFreshnessGoodSeconds] = useState('600'); + const [aiFreshnessFairSeconds, setAiFreshnessFairSeconds] = useState('1800'); const [sidebarSearch, setSidebarSearch] = useState(''); const [activeSection, setActiveSection] = useState(() => { const hash = (typeof location.hash === 'string' && location.hash.slice(1)) || ''; @@ -434,10 +449,63 @@ export default function SettingsPage() { })) ); + const ai = data?.aiRouteOptimizer || {}; + setAiLatencyWeight( + ai?.latencyWeight != null ? String(ai.latencyWeight) : '0.55' + ); + setAiBandwidthWeight( + ai?.bandwidthWeight != null ? String(ai.bandwidthWeight) : '0.35' + ); + setAiFreshnessWeight( + ai?.freshnessWeight != null ? String(ai.freshnessWeight) : '0.10' + ); + setAiHomeToJhWeight( + ai?.combineHomeToJumphostWeight != null + ? String(ai.combineHomeToJumphostWeight) + : '0.45' + ); + setAiJhToExitWeight( + ai?.combineJumphostToExitWeight != null + ? String(ai.combineJumphostToExitWeight) + : '0.55' + ); + setAiProbabilityScale( + ai?.probabilityScale != null ? String(ai.probabilityScale) : '5' + ); + setAiMinProbabilityGainForSwitch( + ai?.minProbabilityGainForSwitch != null + ? String(ai.minProbabilityGainForSwitch) + : '10' + ); + setAiNoPingScore( + ai?.noPingScore != null ? String(ai.noPingScore) : '0.2' + ); + setAiNoSpeedScore( + ai?.noSpeedScore != null ? String(ai.noSpeedScore) : '0.15' + ); + setAiStaleScore( + ai?.staleScore != null ? String(ai.staleScore) : '0.35' + ); + setAiFreshnessExcellentSeconds( + ai?.freshnessExcellentSeconds != null + ? String(ai.freshnessExcellentSeconds) + : '120' + ); + setAiFreshnessGoodSeconds( + ai?.freshnessGoodSeconds != null + ? String(ai.freshnessGoodSeconds) + : '600' + ); + setAiFreshnessFairSeconds( + ai?.freshnessFairSeconds != null + ? String(ai.freshnessFairSeconds) + : '1800' + ); + const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || ''; setEtag(e ? String(e) : ''); - } catch (e) { + } catch { setError('Не удалось загрузить настройки'); } finally { setLoading(false); @@ -590,6 +658,51 @@ export default function SettingsPage() { interfaceName: p.interfaceName, })) : [], + aiRouteOptimizer: { + latencyWeight: Math.max(0, parseFloat(aiLatencyWeight) || 0.55), + bandwidthWeight: Math.max(0, parseFloat(aiBandwidthWeight) || 0.35), + freshnessWeight: Math.max(0, parseFloat(aiFreshnessWeight) || 0.1), + combineHomeToJumphostWeight: Math.max( + 0, + parseFloat(aiHomeToJhWeight) || 0.45 + ), + combineJumphostToExitWeight: Math.max( + 0, + parseFloat(aiJhToExitWeight) || 0.55 + ), + probabilityScale: Math.max( + 0.5, + Math.min(20, parseFloat(aiProbabilityScale) || 5) + ), + minProbabilityGainForSwitch: Math.max( + 0, + Math.min(100, parseFloat(aiMinProbabilityGainForSwitch) || 10) + ), + noPingScore: Math.max( + 0, + Math.min(1, parseFloat(aiNoPingScore) || 0.2) + ), + noSpeedScore: Math.max( + 0, + Math.min(1, parseFloat(aiNoSpeedScore) || 0.15) + ), + staleScore: Math.max( + 0, + Math.min(1, parseFloat(aiStaleScore) || 0.35) + ), + freshnessExcellentSeconds: Math.max( + 10, + Math.min(86400, parseInt(aiFreshnessExcellentSeconds, 10) || 120) + ), + freshnessGoodSeconds: Math.max( + 10, + Math.min(86400, parseInt(aiFreshnessGoodSeconds, 10) || 600) + ), + freshnessFairSeconds: Math.max( + 10, + Math.min(86400, parseInt(aiFreshnessFairSeconds, 10) || 1800) + ), + }, alertSettings: { serverOffline: { enabled: alertServerOffline, @@ -1297,6 +1410,202 @@ export default function SettingsPage() { )} + {activeSection === 'route-ai' && ( + <> + +

+ Настройка правил локального AI: веса метрик, вероятностная модель и пороги решений + для рекомендаций по связке community -> gateway. +

+ +
+

Веса метрик сегмента

+
+ +
+
+ +
+
+ +
+ +

Сборка полного маршрута

+
+ +
+
+ +
+ +

Вероятности и решения

+
+ +
+
+ +
+ +

Поведение при неполных данных

+
+ +
+
+ +
+
+ +
+ +

Пороги свежести (сек)

+
+ +
+
+ +
+
+ +
+
+ + )} + {activeSection === 'alerts' && ( <>