diff --git a/backend/routes/routeOptimizerRoutes.js b/backend/routes/routeOptimizerRoutes.js index fb9548f..462b602 100644 --- a/backend/routes/routeOptimizerRoutes.js +++ b/backend/routes/routeOptimizerRoutes.js @@ -4,7 +4,7 @@ */ const { sendError } = require('../middleware/errorHandler'); -const { readS3TextObject } = require('../services/s3Service'); +const { readS3TextObject, listS3Objects } = require('../services/s3Service'); const { readServersFromS3 } = require('./serversRoutes'); const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; @@ -89,6 +89,59 @@ function compactServer(server) { }; } +async function loadCommunitiesIndex() { + try { + const raw = await readS3TextObject('bgp_data/communities.json').catch(() => null); + if (!raw?.body) return new Map(); + const arr = JSON.parse(raw.body || '[]'); + const m = new Map(); + if (!Array.isArray(arr)) return m; + for (const c of arr) { + const value = String(c?.value || '').trim(); + if (!value) continue; + m.set(value, { + value, + name: c?.name ? String(c.name) : '', + description: c?.description ? String(c.description) : '', + tags: Array.isArray(c?.tags) ? c.tags.map(String) : [], + }); + } + return m; + } catch (_) { + return new Map(); + } +} + +function parseServerIdFromFilterKey(key) { + const m = String(key || '').match(/^filter-manager\/server-filters-(.+)\.json$/); + return m ? m[1] : null; +} + +async function loadServerFiltersByServerId() { + const out = new Map(); + try { + const objects = await listS3Objects('filter-manager/server-filters-', { maxKeys: 500 }); + for (const obj of objects) { + const key = obj?.key; + const serverId = parseServerIdFromFilterKey(key); + if (!serverId) continue; + try { + const raw = await readS3TextObject(key).catch(() => null); + const parsed = raw?.body ? JSON.parse(raw.body || '[]') : []; + const list = Array.isArray(parsed) ? parsed : []; + out.set(serverId, list.filter((f) => f && f.community && f.gateway).map((f) => ({ + community: String(f.community), + gateway: String(f.gateway), + description: f.description ? String(f.description) : '', + }))); + } catch (_) { + out.set(serverId, []); + } + } + } catch (_) {} + return out; +} + async function loadNetworkMapCache() { try { const raw = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null); @@ -148,6 +201,8 @@ function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap const base = { interfaceName: iface.name || null, + localIp: iface.localIp || null, + remoteIp: iface.remoteIp || null, pingMs, speedMbps: speedMbps != null ? Number(speedMbps.toFixed(2)) : null, speedDownloadMbps: downBps != null ? Number((downBps / 1e6).toFixed(2)) : null, @@ -173,10 +228,12 @@ function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap if ((t1 === 'jumphost' && t2 === 'exit') || (t1 === 'exit' && t2 === 'jumphost')) { const jumphost = t1 === 'jumphost' ? s1 : s2; const exit = t1 === 'exit' ? s1 : s2; + const gatewayIpForJumphost = t1 === 'jumphost' ? (iface.remoteIp || null) : (iface.localIp || null); jhToExit.push({ id: `${makeServerKey(jumphost)}->${makeServerKey(exit)}::${base.interfaceName || 'iface'}`, jumphostKey: makeServerKey(jumphost), exitKey: makeServerKey(exit), + gatewayIpForJumphost, jumphost: compactServer(jumphost), exit: compactServer(exit), ...base, @@ -206,12 +263,93 @@ function groupBy(items, keyGetter) { return m; } +function buildCommunityOptimization({ + exitsByJh, + serverFiltersByServerId, + communitiesIndex, +}) { + const jumphostByCommunity = []; + + for (const [jumphostKey, rawCandidates] of exitsByJh.entries()) { + const candidates = enrichProbabilities([...rawCandidates].sort((a, b) => b.score - a.score)); + const gatewayBestMap = new Map(); + for (const c of candidates) { + const gw = String(c.gatewayIpForJumphost || '').trim(); + if (!gw) continue; + if (!gatewayBestMap.has(gw) || gatewayBestMap.get(gw).score < c.score) { + gatewayBestMap.set(gw, c); + } + } + + const filters = serverFiltersByServerId.get(jumphostKey) || []; + if (filters.length === 0) { + jumphostByCommunity.push({ + jumphost: candidates[0]?.jumphost || null, + candidates, + recommendations: [], + }); + continue; + } + + const recommendations = filters.map((f) => { + const currentGateway = String(f.gateway || '').trim(); + const currentCandidate = gatewayBestMap.get(currentGateway) || null; + const recommendedCandidate = candidates[0] || null; + const communityInfo = communitiesIndex.get(String(f.community)) || null; + const shouldSwitch = Boolean( + recommendedCandidate && + currentCandidate && + recommendedCandidate.gatewayIpForJumphost !== currentCandidate.gatewayIpForJumphost && + (recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >= 10 + ); + + return { + community: String(f.community), + communityInfo: communityInfo || { + value: String(f.community), + name: '', + description: f.description || '', + tags: [], + }, + currentGateway: currentGateway || null, + current: currentCandidate ? { + gateway: currentCandidate.gatewayIpForJumphost, + exit: currentCandidate.exit, + score: currentCandidate.score, + probabilityOptimal: currentCandidate.probabilityOptimal, + pingMs: currentCandidate.pingMs, + speedMbps: currentCandidate.speedMbps, + } : null, + recommended: recommendedCandidate ? { + gateway: recommendedCandidate.gatewayIpForJumphost, + exit: recommendedCandidate.exit, + score: recommendedCandidate.score, + probabilityOptimal: recommendedCandidate.probabilityOptimal, + pingMs: recommendedCandidate.pingMs, + speedMbps: recommendedCandidate.speedMbps, + } : null, + shouldSwitch, + }; + }); + + jumphostByCommunity.push({ + jumphost: candidates[0]?.jumphost || null, + candidates, + recommendations, + }); + } + + return jumphostByCommunity; +} + async function getRouteOptimizer(req, res) { try { - const [servers, networkConfig, networkMapCache] = await Promise.all([ + const [servers, networkConfig, networkMapCache, communitiesIndex, serverFiltersByServerId] = await Promise.all([ readServersFromS3(), loadNetworkConfig(), loadNetworkMapCache(), + loadCommunitiesIndex(), + loadServerFiltersByServerId(), ]); const tunnelInterfaces = Array.isArray(networkConfig.tunnelInterfaces) @@ -288,6 +426,12 @@ async function getRouteOptimizer(req, res) { }); } + const communityOptimization = buildCommunityOptimization({ + exitsByJh, + serverFiltersByServerId, + communitiesIndex, + }); + homes.sort((a, b) => { const pa = a.bestFullRoute?.probabilityOptimal ?? 0; const pb = b.bestFullRoute?.probabilityOptimal ?? 0; @@ -300,10 +444,12 @@ async function getRouteOptimizer(req, res) { metricsUpdatedAt: networkMapCache.updatedAt || null, homes, jumphostToExitByJumphost: jumphostSummaries, + communityOptimizationByJumphost: communityOptimization, totals: { homes: homes.length, homeToJumphostCandidates: homeToJh.length, jumphostToExitCandidates: jhToExit.length, + jumphostsWithCommunityFilters: communityOptimization.filter((x) => (x.recommendations || []).length > 0).length, }, }); } catch (error) { diff --git a/frontend/src/RouteOptimizerPage.jsx b/frontend/src/RouteOptimizerPage.jsx index ebc21a1..bc71f8e 100644 --- a/frontend/src/RouteOptimizerPage.jsx +++ b/frontend/src/RouteOptimizerPage.jsx @@ -43,6 +43,9 @@ export default function RouteOptimizerPage() { }, [load]); const homes = Array.isArray(data?.homes) ? data.homes : []; + const communityOptimization = Array.isArray(data?.communityOptimizationByJumphost) + ? data.communityOptimizationByJumphost + : []; return (
@@ -81,7 +84,7 @@ export default function RouteOptimizerPage() { const home = entry.home || {}; const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : []; return ( -
+

@@ -162,6 +165,84 @@ export default function RouteOptimizerPage() { ); })}

+ +
+

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

+
+ Рекомендации строятся на основе ваших `server-filters` (`community -> gateway`) и текущих метрик канала + `jumphost -> exit`. +
+ + {communityOptimization.length === 0 ? ( +
Нет данных по `server-filters` для jumphost.
+ ) : ( +
+ {communityOptimization.map((item) => { + const jh = item.jumphost || {}; + const rows = Array.isArray(item.recommendations) ? item.recommendations : []; + return ( +
+
+
+

+ Jumphost: {jh.label || jh.dns || jh.ip || 'unknown'} +

+ +
+
+
+ + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : rows.map((r) => ( + + + + + + + + ))} + +
CommunityТекущий gatewayРекомендуемый gatewayВероятность (тек/реком)Действие
+ Для этого jumphost нет фильтров. +
+
{r.community}
+ {(r.communityInfo?.name || r.communityInfo?.description) && ( +
+ {r.communityInfo?.name || r.communityInfo?.description} +
+ )} +
{r.current?.gateway || r.currentGateway || '—'}{r.recommended?.gateway || '—'} + {r.current?.probabilityOptimal ?? '—'}% / {r.recommended?.probabilityOptimal ?? '—'}% + + {r.shouldSwitch ? ( + Рекомендовано переключить + ) : ( + Оставить текущий + )} +
+
+
+
+
+ ); + })} +
+ )} +
); }