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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m31s
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>Текущий (рекурсивный -> туннель)</th>
|
||||
<th>Рекомендуемый (рекурсивный -> туннель)</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user