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,
});
+30 -6
View File
@@ -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() {
<thead>
<tr>
<th>Community</th>
<th>Текущий gateway</th>
<th>Рекомендуемый gateway</th>
<th>Текущий (рекурсивный -&gt; туннель)</th>
<th>Рекомендуемый (рекурсивный -&gt; туннель)</th>
<th>Вероятность (тек/реком)</th>
<th>Действие</th>
</tr>
@@ -225,13 +229,33 @@ export default function RouteOptimizerPage() {
</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 ?? ''}%
<div>{r.current?.gateway || r.currentGateway || '—'}</div>
{r.current?.tunnelGateway && (
<div className="text-muted">
{'->'} {r.current.tunnelGateway}
{r.current.distance != null ? ` (dist ${r.current.distance})` : ''}
</div>
)}
</td>
<td className="small">
<div>{r.recommended?.gateway || '—'}</div>
{r.recommended?.tunnelGateway && (
<div className="text-muted">
{'->'} {r.recommended.tunnelGateway}
{r.recommended.distance != null ? ` (dist ${r.recommended.distance})` : ''}
</div>
)}
</td>
<td className="small">
{fmtProb(r.current?.probabilityOptimal)} / {fmtProb(r.recommended?.probabilityOptimal)}
</td>
<td>
{r.shouldSwitch ? (
{r.current == null && r.recommended == null ? (
<span className="badge bg-secondary-lt text-secondary">Недостаточно данных</span>
) : r.current == null ? (
<span className="badge bg-orange-lt text-orange">Текущий gateway не сопоставлен</span>
) : r.shouldSwitch ? (
<span className="badge bg-orange-lt text-orange">Рекомендовано переключить</span>
) : (
<span className="badge bg-green-lt text-green">Оставить текущий</span>
+24
View File
@@ -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"
/>
</div>
<div className="col-12 col-md-4">
<FormField
label="Штраф за шаг distance"
name="aiDistancePenaltyPerStep"
type="number"
value={aiDistancePenaltyPerStep}
onChange={setAiDistancePenaltyPerStep}
helpText="Вычитается из score за каждый шаг distance выше 1."
disabled={saving}
min={0}
max={1}
step="0.01"
/>
</div>
<div className="col-12 mt-2"><h4 className="subheader">Пороги свежести (сек)</h4></div>
<div className="col-12 col-md-4">