feat(route-optimizer): implement community optimization logic and enhance UI for server filters recommendations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { sendError } = require('../middleware/errorHandler');
|
const { sendError } = require('../middleware/errorHandler');
|
||||||
const { readS3TextObject } = require('../services/s3Service');
|
const { readS3TextObject, listS3Objects } = require('../services/s3Service');
|
||||||
const { readServersFromS3 } = require('./serversRoutes');
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
|
|
||||||
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
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() {
|
async function loadNetworkMapCache() {
|
||||||
try {
|
try {
|
||||||
const raw = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
|
const raw = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
|
||||||
@@ -148,6 +201,8 @@ function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap
|
|||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
interfaceName: iface.name || null,
|
interfaceName: iface.name || null,
|
||||||
|
localIp: iface.localIp || null,
|
||||||
|
remoteIp: iface.remoteIp || null,
|
||||||
pingMs,
|
pingMs,
|
||||||
speedMbps: speedMbps != null ? Number(speedMbps.toFixed(2)) : null,
|
speedMbps: speedMbps != null ? Number(speedMbps.toFixed(2)) : null,
|
||||||
speedDownloadMbps: downBps != null ? Number((downBps / 1e6).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')) {
|
if ((t1 === 'jumphost' && t2 === 'exit') || (t1 === 'exit' && t2 === 'jumphost')) {
|
||||||
const jumphost = t1 === 'jumphost' ? s1 : s2;
|
const jumphost = t1 === 'jumphost' ? s1 : s2;
|
||||||
const exit = t1 === 'exit' ? s1 : s2;
|
const exit = t1 === 'exit' ? s1 : s2;
|
||||||
|
const gatewayIpForJumphost = t1 === 'jumphost' ? (iface.remoteIp || null) : (iface.localIp || null);
|
||||||
jhToExit.push({
|
jhToExit.push({
|
||||||
id: `${makeServerKey(jumphost)}->${makeServerKey(exit)}::${base.interfaceName || 'iface'}`,
|
id: `${makeServerKey(jumphost)}->${makeServerKey(exit)}::${base.interfaceName || 'iface'}`,
|
||||||
jumphostKey: makeServerKey(jumphost),
|
jumphostKey: makeServerKey(jumphost),
|
||||||
exitKey: makeServerKey(exit),
|
exitKey: makeServerKey(exit),
|
||||||
|
gatewayIpForJumphost,
|
||||||
jumphost: compactServer(jumphost),
|
jumphost: compactServer(jumphost),
|
||||||
exit: compactServer(exit),
|
exit: compactServer(exit),
|
||||||
...base,
|
...base,
|
||||||
@@ -206,12 +263,93 @@ function groupBy(items, keyGetter) {
|
|||||||
return m;
|
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) {
|
async function getRouteOptimizer(req, res) {
|
||||||
try {
|
try {
|
||||||
const [servers, networkConfig, networkMapCache] = await Promise.all([
|
const [servers, networkConfig, networkMapCache, communitiesIndex, serverFiltersByServerId] = await Promise.all([
|
||||||
readServersFromS3(),
|
readServersFromS3(),
|
||||||
loadNetworkConfig(),
|
loadNetworkConfig(),
|
||||||
loadNetworkMapCache(),
|
loadNetworkMapCache(),
|
||||||
|
loadCommunitiesIndex(),
|
||||||
|
loadServerFiltersByServerId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tunnelInterfaces = Array.isArray(networkConfig.tunnelInterfaces)
|
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) => {
|
homes.sort((a, b) => {
|
||||||
const pa = a.bestFullRoute?.probabilityOptimal ?? 0;
|
const pa = a.bestFullRoute?.probabilityOptimal ?? 0;
|
||||||
const pb = b.bestFullRoute?.probabilityOptimal ?? 0;
|
const pb = b.bestFullRoute?.probabilityOptimal ?? 0;
|
||||||
@@ -300,10 +444,12 @@ async function getRouteOptimizer(req, res) {
|
|||||||
metricsUpdatedAt: networkMapCache.updatedAt || null,
|
metricsUpdatedAt: networkMapCache.updatedAt || null,
|
||||||
homes,
|
homes,
|
||||||
jumphostToExitByJumphost: jumphostSummaries,
|
jumphostToExitByJumphost: jumphostSummaries,
|
||||||
|
communityOptimizationByJumphost: communityOptimization,
|
||||||
totals: {
|
totals: {
|
||||||
homes: homes.length,
|
homes: homes.length,
|
||||||
homeToJumphostCandidates: homeToJh.length,
|
homeToJumphostCandidates: homeToJh.length,
|
||||||
jumphostToExitCandidates: jhToExit.length,
|
jumphostToExitCandidates: jhToExit.length,
|
||||||
|
jumphostsWithCommunityFilters: communityOptimization.filter((x) => (x.recommendations || []).length > 0).length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export default function RouteOptimizerPage() {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const homes = Array.isArray(data?.homes) ? data.homes : [];
|
const homes = Array.isArray(data?.homes) ? data.homes : [];
|
||||||
|
const communityOptimization = Array.isArray(data?.communityOptimizationByJumphost)
|
||||||
|
? data.communityOptimizationByJumphost
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -81,7 +84,7 @@ export default function RouteOptimizerPage() {
|
|||||||
const home = entry.home || {};
|
const home = entry.home || {};
|
||||||
const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : [];
|
const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : [];
|
||||||
return (
|
return (
|
||||||
<div className="col-12" key={home.id || home.ip || home.dns || Math.random()}>
|
<div className="col-12" key={home.id || home.ip || home.dns || home.label || 'home'}>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
<div className="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||||
<h3 className="card-title mb-0 d-flex align-items-center">
|
<h3 className="card-title mb-0 d-flex align-items-center">
|
||||||
@@ -162,6 +165,84 @@ export default function RouteOptimizerPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="mb-3">Оптимизация по community и filters</h3>
|
||||||
|
<div className="text-muted small mb-3">
|
||||||
|
Рекомендации строятся на основе ваших `server-filters` (`community -> gateway`) и текущих метрик канала
|
||||||
|
`jumphost -> exit`.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{communityOptimization.length === 0 ? (
|
||||||
|
<div className="alert alert-info">Нет данных по `server-filters` для jumphost.</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3">
|
||||||
|
{communityOptimization.map((item) => {
|
||||||
|
const jh = item.jumphost || {};
|
||||||
|
const rows = Array.isArray(item.recommendations) ? item.recommendations : [];
|
||||||
|
return (
|
||||||
|
<div className="col-12" key={jh.id || jh.ip || jh.dns || jh.label || 'jumphost'}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<h3 className="card-title mb-0">
|
||||||
|
Jumphost: {jh.label || jh.dns || jh.ip || 'unknown'}
|
||||||
|
</h3>
|
||||||
|
<MetricBadge label="Communities" value={rows.length} tone="blue" />
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Community</th>
|
||||||
|
<th>Текущий gateway</th>
|
||||||
|
<th>Рекомендуемый gateway</th>
|
||||||
|
<th>Вероятность (тек/реком)</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-muted">
|
||||||
|
Для этого jumphost нет фильтров.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : rows.map((r) => (
|
||||||
|
<tr key={`${jh.label}-${r.community}`}>
|
||||||
|
<td>
|
||||||
|
<div className="fw-semibold">{r.community}</div>
|
||||||
|
{(r.communityInfo?.name || r.communityInfo?.description) && (
|
||||||
|
<div className="small text-muted">
|
||||||
|
{r.communityInfo?.name || r.communityInfo?.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="small">{r.current?.gateway || r.currentGateway || '—'}</td>
|
||||||
|
<td className="small">{r.recommended?.gateway || '—'}</td>
|
||||||
|
<td className="small">
|
||||||
|
{r.current?.probabilityOptimal ?? '—'}% / {r.recommended?.probabilityOptimal ?? '—'}%
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{r.shouldSwitch ? (
|
||||||
|
<span className="badge bg-orange-lt text-orange">Рекомендовано переключить</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-green-lt text-green">Оставить текущий</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user