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,
|
freshnessExcellentSeconds: 120,
|
||||||
freshnessGoodSeconds: 600,
|
freshnessGoodSeconds: 600,
|
||||||
freshnessFairSeconds: 1800,
|
freshnessFairSeconds: 1800,
|
||||||
|
distancePenaltyPerStep: 0.03,
|
||||||
};
|
};
|
||||||
|
|
||||||
function asObject(v) {
|
function asObject(v) {
|
||||||
@@ -64,7 +65,7 @@ function pairProbability(items, scoreGetter) {
|
|||||||
if (!Array.isArray(items) || items.length === 0) return [];
|
if (!Array.isArray(items) || items.length === 0) return [];
|
||||||
const values = items.map((it) => Number(scoreGetter(it) || 0));
|
const values = items.map((it) => Number(scoreGetter(it) || 0));
|
||||||
const max = Math.max(...values);
|
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;
|
const sum = exps.reduce((acc, x) => acc + x, 0) || 1;
|
||||||
return exps.map((x) => (x / sum) * 100);
|
return exps.map((x) => (x / sum) * 100);
|
||||||
}
|
}
|
||||||
@@ -252,6 +253,11 @@ async function loadAiSettings() {
|
|||||||
10,
|
10,
|
||||||
86400
|
86400
|
||||||
),
|
),
|
||||||
|
distancePenaltyPerStep: clamp(
|
||||||
|
positiveNumber(src.distancePenaltyPerStep, DEFAULT_AI_SETTINGS.distancePenaltyPerStep),
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return { ...DEFAULT_AI_SETTINGS };
|
return { ...DEFAULT_AI_SETTINGS };
|
||||||
@@ -353,16 +359,163 @@ function groupBy(items, keyGetter) {
|
|||||||
return m;
|
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({
|
function buildCommunityOptimization({
|
||||||
exitsByJh,
|
exitsByJh,
|
||||||
serverFiltersByServerId,
|
serverFiltersByServerId,
|
||||||
communitiesIndex,
|
communitiesIndex,
|
||||||
|
networkConfig,
|
||||||
|
servers,
|
||||||
aiSettings,
|
aiSettings,
|
||||||
}) {
|
}) {
|
||||||
const jumphostByCommunity = [];
|
const jumphostByCommunity = [];
|
||||||
|
|
||||||
for (const [jumphostKey, rawCandidates] of exitsByJh.entries()) {
|
for (const [jumphostKey, rawCandidates] of exitsByJh.entries()) {
|
||||||
const candidates = enrichProbabilities([...rawCandidates].sort((a, b) => b.score - a.score), 'score', aiSettings);
|
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();
|
const gatewayBestMap = new Map();
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
const gw = String(c.gatewayIpForJumphost || '').trim();
|
const gw = String(c.gatewayIpForJumphost || '').trim();
|
||||||
@@ -371,12 +524,19 @@ function buildCommunityOptimization({
|
|||||||
gatewayBestMap.set(gw, c);
|
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) || [];
|
const filters = serverFiltersByServerId.get(jumphostKey) || [];
|
||||||
if (filters.length === 0) {
|
if (filters.length === 0) {
|
||||||
jumphostByCommunity.push({
|
jumphostByCommunity.push({
|
||||||
jumphost: candidates[0]?.jumphost || null,
|
jumphost,
|
||||||
candidates,
|
candidates,
|
||||||
|
recursiveGatewayOptions: recursiveOptions,
|
||||||
recommendations: [],
|
recommendations: [],
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -384,13 +544,21 @@ function buildCommunityOptimization({
|
|||||||
|
|
||||||
const recommendations = filters.map((f) => {
|
const recommendations = filters.map((f) => {
|
||||||
const currentGateway = String(f.gateway || '').trim();
|
const currentGateway = String(f.gateway || '').trim();
|
||||||
const currentCandidate = gatewayBestMap.get(currentGateway) || null;
|
const currentCandidate =
|
||||||
const recommendedCandidate = candidates[0] || null;
|
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 communityInfo = communitiesIndex.get(String(f.community)) || null;
|
||||||
const shouldSwitch = Boolean(
|
const shouldSwitch = Boolean(
|
||||||
recommendedCandidate &&
|
recommendedCandidate &&
|
||||||
currentCandidate &&
|
currentCandidate &&
|
||||||
recommendedCandidate.gatewayIpForJumphost !== currentCandidate.gatewayIpForJumphost &&
|
(recommendedCandidate.recursiveGateway || recommendedCandidate.gatewayIpForJumphost) !==
|
||||||
|
(currentCandidate.recursiveGateway || currentCandidate.gatewayIpForJumphost) &&
|
||||||
(recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >= aiSettings.minProbabilityGainForSwitch
|
(recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >= aiSettings.minProbabilityGainForSwitch
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -404,7 +572,9 @@ function buildCommunityOptimization({
|
|||||||
},
|
},
|
||||||
currentGateway: currentGateway || null,
|
currentGateway: currentGateway || null,
|
||||||
current: currentCandidate ? {
|
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,
|
exit: currentCandidate.exit,
|
||||||
score: currentCandidate.score,
|
score: currentCandidate.score,
|
||||||
probabilityOptimal: currentCandidate.probabilityOptimal,
|
probabilityOptimal: currentCandidate.probabilityOptimal,
|
||||||
@@ -412,7 +582,9 @@ function buildCommunityOptimization({
|
|||||||
speedMbps: currentCandidate.speedMbps,
|
speedMbps: currentCandidate.speedMbps,
|
||||||
} : null,
|
} : null,
|
||||||
recommended: recommendedCandidate ? {
|
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,
|
exit: recommendedCandidate.exit,
|
||||||
score: recommendedCandidate.score,
|
score: recommendedCandidate.score,
|
||||||
probabilityOptimal: recommendedCandidate.probabilityOptimal,
|
probabilityOptimal: recommendedCandidate.probabilityOptimal,
|
||||||
@@ -424,8 +596,9 @@ function buildCommunityOptimization({
|
|||||||
});
|
});
|
||||||
|
|
||||||
jumphostByCommunity.push({
|
jumphostByCommunity.push({
|
||||||
jumphost: candidates[0]?.jumphost || null,
|
jumphost,
|
||||||
candidates,
|
candidates,
|
||||||
|
recursiveGatewayOptions: recursiveOptions,
|
||||||
recommendations,
|
recommendations,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -528,6 +701,8 @@ async function getRouteOptimizer(req, res) {
|
|||||||
exitsByJh,
|
exitsByJh,
|
||||||
serverFiltersByServerId,
|
serverFiltersByServerId,
|
||||||
communitiesIndex,
|
communitiesIndex,
|
||||||
|
networkConfig,
|
||||||
|
servers,
|
||||||
aiSettings,
|
aiSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ function MetricBadge({ label, value, tone = 'secondary' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtProb(v) {
|
||||||
|
return typeof v === 'number' ? `${v}%` : '—';
|
||||||
|
}
|
||||||
|
|
||||||
export default function RouteOptimizerPage() {
|
export default function RouteOptimizerPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -202,8 +206,8 @@ export default function RouteOptimizerPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Community</th>
|
<th>Community</th>
|
||||||
<th>Текущий gateway</th>
|
<th>Текущий (рекурсивный -> туннель)</th>
|
||||||
<th>Рекомендуемый gateway</th>
|
<th>Рекомендуемый (рекурсивный -> туннель)</th>
|
||||||
<th>Вероятность (тек/реком)</th>
|
<th>Вероятность (тек/реком)</th>
|
||||||
<th>Действие</th>
|
<th>Действие</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -225,13 +229,33 @@ export default function RouteOptimizerPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="small">{r.current?.gateway || r.currentGateway || '—'}</td>
|
|
||||||
<td className="small">{r.recommended?.gateway || '—'}</td>
|
|
||||||
<td className="small">
|
<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>
|
||||||
<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-orange-lt text-orange">Рекомендовано переключить</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge bg-green-lt text-green">Оставить текущий</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 [aiNoPingScore, setAiNoPingScore] = useState('0.2');
|
||||||
const [aiNoSpeedScore, setAiNoSpeedScore] = useState('0.15');
|
const [aiNoSpeedScore, setAiNoSpeedScore] = useState('0.15');
|
||||||
const [aiStaleScore, setAiStaleScore] = useState('0.35');
|
const [aiStaleScore, setAiStaleScore] = useState('0.35');
|
||||||
|
const [aiDistancePenaltyPerStep, setAiDistancePenaltyPerStep] = useState('0.03');
|
||||||
const [aiFreshnessExcellentSeconds, setAiFreshnessExcellentSeconds] = useState('120');
|
const [aiFreshnessExcellentSeconds, setAiFreshnessExcellentSeconds] = useState('120');
|
||||||
const [aiFreshnessGoodSeconds, setAiFreshnessGoodSeconds] = useState('600');
|
const [aiFreshnessGoodSeconds, setAiFreshnessGoodSeconds] = useState('600');
|
||||||
const [aiFreshnessFairSeconds, setAiFreshnessFairSeconds] = useState('1800');
|
const [aiFreshnessFairSeconds, setAiFreshnessFairSeconds] = useState('1800');
|
||||||
@@ -486,6 +487,11 @@ export default function SettingsPage() {
|
|||||||
setAiStaleScore(
|
setAiStaleScore(
|
||||||
ai?.staleScore != null ? String(ai.staleScore) : '0.35'
|
ai?.staleScore != null ? String(ai.staleScore) : '0.35'
|
||||||
);
|
);
|
||||||
|
setAiDistancePenaltyPerStep(
|
||||||
|
ai?.distancePenaltyPerStep != null
|
||||||
|
? String(ai.distancePenaltyPerStep)
|
||||||
|
: '0.03'
|
||||||
|
);
|
||||||
setAiFreshnessExcellentSeconds(
|
setAiFreshnessExcellentSeconds(
|
||||||
ai?.freshnessExcellentSeconds != null
|
ai?.freshnessExcellentSeconds != null
|
||||||
? String(ai.freshnessExcellentSeconds)
|
? String(ai.freshnessExcellentSeconds)
|
||||||
@@ -690,6 +696,10 @@ export default function SettingsPage() {
|
|||||||
0,
|
0,
|
||||||
Math.min(1, parseFloat(aiStaleScore) || 0.35)
|
Math.min(1, parseFloat(aiStaleScore) || 0.35)
|
||||||
),
|
),
|
||||||
|
distancePenaltyPerStep: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, parseFloat(aiDistancePenaltyPerStep) || 0.03)
|
||||||
|
),
|
||||||
freshnessExcellentSeconds: Math.max(
|
freshnessExcellentSeconds: Math.max(
|
||||||
10,
|
10,
|
||||||
Math.min(86400, parseInt(aiFreshnessExcellentSeconds, 10) || 120)
|
Math.min(86400, parseInt(aiFreshnessExcellentSeconds, 10) || 120)
|
||||||
@@ -1561,6 +1571,20 @@ export default function SettingsPage() {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 mt-2"><h4 className="subheader">Пороги свежести (сек)</h4></div>
|
||||||
<div className="col-12 col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user