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={
server-filters (связки
+ community -> gateway) и текущих метрик канала
+ jumphost -> exit.
server-filters для jumphost.
+ Настройка правил локального AI: веса метрик, вероятностная модель и пороги решений
+ для рекомендаций по связке community -> gateway.
+