feat(route-optimizer): add distance penalty configuration and enhance UI for gateway recommendations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m31s

This commit is contained in:
2026-03-04 23:28:24 +07:00
parent 7b7ef1ab45
commit f5829fb35c
3 changed files with 237 additions and 14 deletions
+183 -8
View File
@@ -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,
});