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

This commit is contained in:
2026-03-04 23:17:59 +07:00
parent 661b42f776
commit 963828f9e8
2 changed files with 230 additions and 3 deletions
+148 -2
View File
@@ -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) {
+82 -1
View File
@@ -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 (
<div>
@@ -81,7 +84,7 @@ export default function RouteOptimizerPage() {
const home = entry.home || {};
const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : [];
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-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
<h3 className="card-title mb-0 d-flex align-items-center">
@@ -162,6 +165,84 @@ export default function RouteOptimizerPage() {
);
})}
</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>
);
}