diff --git a/backend/routes/routeOptimizerRoutes.js b/backend/routes/routeOptimizerRoutes.js index 8313108..ac882f8 100644 --- a/backend/routes/routeOptimizerRoutes.js +++ b/backend/routes/routeOptimizerRoutes.js @@ -25,6 +25,7 @@ const DEFAULT_AI_SETTINGS = { freshnessExcellentSeconds: 120, freshnessGoodSeconds: 600, freshnessFairSeconds: 1800, + distancePenaltyPerStep: 0.03, }; function asObject(v) { @@ -64,7 +65,7 @@ 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 exps = values.map((v) => Math.exp(v - max)); const sum = exps.reduce((acc, x) => acc + x, 0) || 1; return exps.map((x) => (x / sum) * 100); } @@ -252,6 +253,11 @@ async function loadAiSettings() { 10, 86400 ), + distancePenaltyPerStep: clamp( + positiveNumber(src.distancePenaltyPerStep, DEFAULT_AI_SETTINGS.distancePenaltyPerStep), + 0, + 1 + ), }; } catch (_) { return { ...DEFAULT_AI_SETTINGS }; @@ -353,16 +359,163 @@ function groupBy(items, keyGetter) { return m; } +function collectServerRefs(server) { + return [server?.id, server?.ip, server?.dns] + .filter(Boolean) + .map((x) => String(x).trim()); +} + +function buildServerIndex(servers) { + const byRef = new Map(); + (Array.isArray(servers) ? servers : []).forEach((s) => { + collectServerRefs(s).forEach((ref) => byRef.set(ref, s)); + }); + return byRef; +} + +function getServerByAnyRef(ref, byRef) { + const r = String(ref || '').trim(); + if (!r) return null; + return byRef.get(r) || null; +} + +function gatewayBelongsToJumphost(gw, jumphost, byRef) { + if (!gw || !jumphost) return false; + const gwServer = getServerByAnyRef(gw.serverId, byRef); + if (!gwServer) return false; + const jhRefs = new Set(collectServerRefs(jumphost)); + return collectServerRefs(gwServer).some((r) => jhRefs.has(r)); +} + +function resolveParentGatewayRef(parentId, networkConfig) { + const gateways = Array.isArray(networkConfig?.gateways) ? networkConfig.gateways : []; + const interfaces = Array.isArray(networkConfig?.tunnelInterfaces) + ? networkConfig.tunnelInterfaces + : []; + const gw = gateways.find((x) => x && x.id === parentId); + if (gw) return { type: 'gateway', value: gw }; + const iface = interfaces.find((x) => x && x.id === parentId); + if (iface) return { type: 'interface', value: iface }; + return null; +} + +function getInterfaceGatewayIpForJumphost(iface, jumphost, byRef) { + const s1 = getServerByAnyRef(iface?.serverId, byRef); + const s2 = getServerByAnyRef(iface?.serverId2, byRef); + if (!s1 || !s2 || !jumphost) return null; + const jhRefs = new Set(collectServerRefs(jumphost)); + const s1isJh = collectServerRefs(s1).some((r) => jhRefs.has(r)); + const s2isJh = collectServerRefs(s2).some((r) => jhRefs.has(r)); + if (s1isJh) return iface.remoteIp || null; + if (s2isJh) return iface.localIp || null; + return null; +} + +function computeDistanceAdjustedScore(score, distance, aiSettings) { + const d = Number.isFinite(Number(distance)) ? Number(distance) : 1; + const penaltySteps = Math.max(0, d - 1); + return clamp(Number(score || 0) - penaltySteps * aiSettings.distancePenaltyPerStep, 0, 1); +} + +function buildRecursiveGatewayOptionsForJumphost({ + jumphost, + jumphostCandidates, + networkConfig, + servers, + aiSettings, +}) { + const byRef = buildServerIndex(servers); + const gateways = Array.isArray(networkConfig?.gateways) ? networkConfig.gateways : []; + const recursive = gateways.filter( + (gw) => + gw && + String(gw.type || '').toLowerCase() === 'recursive' && + gatewayBelongsToJumphost(gw, jumphost, byRef) + ); + if (recursive.length === 0) return []; + + const options = []; + for (const rgw of recursive) { + const parentRefs = + Array.isArray(rgw.parentGateways) && rgw.parentGateways.length > 0 + ? rgw.parentGateways + : rgw.parentGatewayId + ? [{ id: rgw.parentGatewayId, distance: undefined }] + : []; + const mapped = []; + + for (const pref of parentRefs) { + const resolved = resolveParentGatewayRef(pref?.id, networkConfig); + if (!resolved) continue; + const distance = Number.isFinite(Number(pref?.distance)) ? Number(pref.distance) : 1; + let physicalGatewayIp = null; + if (resolved.type === 'gateway') { + physicalGatewayIp = resolved.value?.ip || null; + } else if (resolved.type === 'interface') { + physicalGatewayIp = getInterfaceGatewayIpForJumphost( + resolved.value, + jumphost, + byRef + ); + } + if (!physicalGatewayIp) continue; + + const candidate = (Array.isArray(jumphostCandidates) ? jumphostCandidates : []).find( + (c) => String(c.gatewayIpForJumphost || '') === String(physicalGatewayIp) + ); + if (!candidate) continue; + + mapped.push({ + distance, + physicalGatewayIp, + candidate, + scoreWithDistance: computeDistanceAdjustedScore(candidate.score, distance, aiSettings), + }); + } + + if (mapped.length === 0) continue; + mapped.sort((a, b) => b.scoreWithDistance - a.scoreWithDistance); + const best = mapped[0]; + + options.push({ + recursiveGateway: rgw.ip || null, + recursiveGatewayId: rgw.id || null, + recursiveDescription: rgw.description || '', + tunnelGatewayIp: best.physicalGatewayIp, + distance: best.distance, + exit: best.candidate.exit, + baseScore: best.candidate.score, + score: Number(best.scoreWithDistance.toFixed(4)), + pingMs: best.candidate.pingMs, + speedMbps: best.candidate.speedMbps, + sourceCandidateId: best.candidate.id, + }); + } + + return options.sort((a, b) => b.score - a.score); +} + function buildCommunityOptimization({ exitsByJh, serverFiltersByServerId, communitiesIndex, + networkConfig, + servers, aiSettings, }) { const jumphostByCommunity = []; for (const [jumphostKey, rawCandidates] of exitsByJh.entries()) { const candidates = enrichProbabilities([...rawCandidates].sort((a, b) => b.score - a.score), 'score', aiSettings); + const jumphost = candidates[0]?.jumphost || null; + const recursiveOptionsRaw = buildRecursiveGatewayOptionsForJumphost({ + jumphost, + jumphostCandidates: candidates, + networkConfig, + servers, + aiSettings, + }); + const recursiveOptions = enrichProbabilities(recursiveOptionsRaw, 'score', aiSettings); const gatewayBestMap = new Map(); for (const c of candidates) { const gw = String(c.gatewayIpForJumphost || '').trim(); @@ -371,12 +524,19 @@ function buildCommunityOptimization({ gatewayBestMap.set(gw, c); } } + const recursiveByIp = new Map(); + const recursiveById = new Map(); + recursiveOptions.forEach((o) => { + if (o.recursiveGateway) recursiveByIp.set(String(o.recursiveGateway), o); + if (o.recursiveGatewayId) recursiveById.set(String(o.recursiveGatewayId), o); + }); const filters = serverFiltersByServerId.get(jumphostKey) || []; if (filters.length === 0) { jumphostByCommunity.push({ - jumphost: candidates[0]?.jumphost || null, + jumphost, candidates, + recursiveGatewayOptions: recursiveOptions, recommendations: [], }); continue; @@ -384,13 +544,21 @@ function buildCommunityOptimization({ const recommendations = filters.map((f) => { const currentGateway = String(f.gateway || '').trim(); - const currentCandidate = gatewayBestMap.get(currentGateway) || null; - const recommendedCandidate = candidates[0] || null; + const currentCandidate = + recursiveByIp.get(currentGateway) || + recursiveById.get(currentGateway) || + gatewayBestMap.get(currentGateway) || + null; + const recommendedCandidate = + (recursiveOptions.length > 0 ? recursiveOptions[0] : null) || + candidates[0] || + null; const communityInfo = communitiesIndex.get(String(f.community)) || null; const shouldSwitch = Boolean( recommendedCandidate && currentCandidate && - recommendedCandidate.gatewayIpForJumphost !== currentCandidate.gatewayIpForJumphost && + (recommendedCandidate.recursiveGateway || recommendedCandidate.gatewayIpForJumphost) !== + (currentCandidate.recursiveGateway || currentCandidate.gatewayIpForJumphost) && (recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >= aiSettings.minProbabilityGainForSwitch ); @@ -404,7 +572,9 @@ function buildCommunityOptimization({ }, currentGateway: currentGateway || null, current: currentCandidate ? { - gateway: currentCandidate.gatewayIpForJumphost, + gateway: currentCandidate.recursiveGateway || currentCandidate.gatewayIpForJumphost || null, + tunnelGateway: currentCandidate.tunnelGatewayIp || currentCandidate.gatewayIpForJumphost || null, + distance: currentCandidate.distance != null ? currentCandidate.distance : null, exit: currentCandidate.exit, score: currentCandidate.score, probabilityOptimal: currentCandidate.probabilityOptimal, @@ -412,7 +582,9 @@ function buildCommunityOptimization({ speedMbps: currentCandidate.speedMbps, } : null, recommended: recommendedCandidate ? { - gateway: recommendedCandidate.gatewayIpForJumphost, + gateway: recommendedCandidate.recursiveGateway || recommendedCandidate.gatewayIpForJumphost || null, + tunnelGateway: recommendedCandidate.tunnelGatewayIp || recommendedCandidate.gatewayIpForJumphost || null, + distance: recommendedCandidate.distance != null ? recommendedCandidate.distance : null, exit: recommendedCandidate.exit, score: recommendedCandidate.score, probabilityOptimal: recommendedCandidate.probabilityOptimal, @@ -424,8 +596,9 @@ function buildCommunityOptimization({ }); jumphostByCommunity.push({ - jumphost: candidates[0]?.jumphost || null, + jumphost, candidates, + recursiveGatewayOptions: recursiveOptions, recommendations, }); } @@ -528,6 +701,8 @@ async function getRouteOptimizer(req, res) { exitsByJh, serverFiltersByServerId, communitiesIndex, + networkConfig, + servers, aiSettings, }); diff --git a/frontend/src/RouteOptimizerPage.jsx b/frontend/src/RouteOptimizerPage.jsx index 24543e6..db4e2b2 100644 --- a/frontend/src/RouteOptimizerPage.jsx +++ b/frontend/src/RouteOptimizerPage.jsx @@ -17,6 +17,10 @@ function MetricBadge({ label, value, tone = 'secondary' }) { ); } +function fmtProb(v) { + return typeof v === 'number' ? `${v}%` : '—'; +} + export default function RouteOptimizerPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -202,8 +206,8 @@ export default function RouteOptimizerPage() { Community - Текущий gateway - Рекомендуемый gateway + Текущий (рекурсивный -> туннель) + Рекомендуемый (рекурсивный -> туннель) Вероятность (тек/реком) Действие @@ -225,13 +229,33 @@ export default function RouteOptimizerPage() { )} - {r.current?.gateway || r.currentGateway || '—'} - {r.recommended?.gateway || '—'} - {r.current?.probabilityOptimal ?? '—'}% / {r.recommended?.probabilityOptimal ?? '—'}% +
{r.current?.gateway || r.currentGateway || '—'}
+ {r.current?.tunnelGateway && ( +
+ {'->'} {r.current.tunnelGateway} + {r.current.distance != null ? ` (dist ${r.current.distance})` : ''} +
+ )} + + +
{r.recommended?.gateway || '—'}
+ {r.recommended?.tunnelGateway && ( +
+ {'->'} {r.recommended.tunnelGateway} + {r.recommended.distance != null ? ` (dist ${r.recommended.distance})` : ''} +
+ )} + + + {fmtProb(r.current?.probabilityOptimal)} / {fmtProb(r.recommended?.probabilityOptimal)} - {r.shouldSwitch ? ( + {r.current == null && r.recommended == null ? ( + Недостаточно данных + ) : r.current == null ? ( + Текущий gateway не сопоставлен + ) : r.shouldSwitch ? ( Рекомендовано переключить ) : ( Оставить текущий diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx index ccfdd69..07d98d1 100644 --- a/frontend/src/SettingsPage.jsx +++ b/frontend/src/SettingsPage.jsx @@ -132,6 +132,7 @@ export default function SettingsPage() { const [aiNoPingScore, setAiNoPingScore] = useState('0.2'); const [aiNoSpeedScore, setAiNoSpeedScore] = useState('0.15'); const [aiStaleScore, setAiStaleScore] = useState('0.35'); + const [aiDistancePenaltyPerStep, setAiDistancePenaltyPerStep] = useState('0.03'); const [aiFreshnessExcellentSeconds, setAiFreshnessExcellentSeconds] = useState('120'); const [aiFreshnessGoodSeconds, setAiFreshnessGoodSeconds] = useState('600'); const [aiFreshnessFairSeconds, setAiFreshnessFairSeconds] = useState('1800'); @@ -486,6 +487,11 @@ export default function SettingsPage() { setAiStaleScore( ai?.staleScore != null ? String(ai.staleScore) : '0.35' ); + setAiDistancePenaltyPerStep( + ai?.distancePenaltyPerStep != null + ? String(ai.distancePenaltyPerStep) + : '0.03' + ); setAiFreshnessExcellentSeconds( ai?.freshnessExcellentSeconds != null ? String(ai.freshnessExcellentSeconds) @@ -690,6 +696,10 @@ export default function SettingsPage() { 0, Math.min(1, parseFloat(aiStaleScore) || 0.35) ), + distancePenaltyPerStep: Math.max( + 0, + Math.min(1, parseFloat(aiDistancePenaltyPerStep) || 0.03) + ), freshnessExcellentSeconds: Math.max( 10, Math.min(86400, parseInt(aiFreshnessExcellentSeconds, 10) || 120) @@ -1561,6 +1571,20 @@ export default function SettingsPage() { step="0.01" /> +
+ +

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