feat(route-optimizer): integrate AI route optimizer functionality into the application with routing endpoint and UI components
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s

This commit is contained in:
2026-03-04 23:09:23 +07:00
parent d31c90b60e
commit 661b42f776
4 changed files with 503 additions and 2 deletions
+323
View File
@@ -0,0 +1,323 @@
/**
* Rule-based "local AI" optimizer for route selection.
* Returns best paths and probability of optimality for all candidates.
*/
const { sendError } = require('../middleware/errorHandler');
const { readS3TextObject } = require('../services/s3Service');
const { readServersFromS3 } = require('./serversRoutes');
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const NETWORK_CONFIG_KEY = 'network-config.json';
function asObject(v) {
return v && typeof v === 'object' ? v : {};
}
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function makeServerKey(server) {
if (!server || typeof server !== 'object') return '';
return String(server.id || server.dns || server.ip || '').trim();
}
function resolveServerRef(ref, byRef) {
const key = String(ref || '').trim();
if (!key) return null;
return byRef.get(key) || null;
}
function pairKey(a, b) {
return [String(a || ''), String(b || '')].sort().join(':');
}
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 sum = exps.reduce((acc, x) => acc + x, 0) || 1;
return exps.map((x) => (x / sum) * 100);
}
function latencyScore(pingMs) {
if (typeof pingMs !== 'number') return 0.2;
const normalized = 1 / (1 + pingMs / 35);
return clamp(normalized, 0, 1);
}
function bandwidthScore(speedMbps) {
if (typeof speedMbps !== 'number' || speedMbps <= 0) return 0.15;
// log-scale to avoid dominance by very high channels
const normalized = Math.log10(1 + speedMbps) / Math.log10(1001);
return clamp(normalized, 0, 1);
}
function freshnessScore(cacheUpdatedAt) {
if (typeof cacheUpdatedAt !== 'number') return 0.4;
const ageMs = Math.max(0, Date.now() - cacheUpdatedAt);
if (ageMs <= 2 * 60 * 1000) return 1;
if (ageMs <= 10 * 60 * 1000) return 0.8;
if (ageMs <= 30 * 60 * 1000) return 0.6;
return 0.35;
}
function buildSegmentScore({ pingMs, speedMbps, cacheUpdatedAt }) {
const l = latencyScore(pingMs);
const b = bandwidthScore(speedMbps);
const f = freshnessScore(cacheUpdatedAt);
const metricPresence = (typeof pingMs === 'number' ? 1 : 0) + (typeof speedMbps === 'number' ? 1 : 0);
const confidence = metricPresence === 2 ? 1 : metricPresence === 1 ? 0.65 : 0.35;
const score = l * 0.55 + b * 0.35 + f * 0.1;
return { score: clamp(score, 0, 1), confidence };
}
function compactServer(server) {
return {
id: server?.id || null,
ip: server?.ip || null,
dns: server?.dns || null,
type: server?.type || null,
label: server?.dns || server?.ip || server?.id || 'unknown',
};
}
async function loadNetworkMapCache() {
try {
const raw = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
if (!raw?.body) return { pingMap: {}, speedMap: {}, updatedAt: null };
const parsed = JSON.parse(raw.body || '{}');
return {
pingMap: asObject(parsed.pingMap),
speedMap: asObject(parsed.speedMap),
updatedAt: toNumber(parsed.updatedAt),
};
} catch (_) {
return { pingMap: {}, speedMap: {}, updatedAt: null };
}
}
async function loadNetworkConfig() {
try {
const raw = await readS3TextObject(NETWORK_CONFIG_KEY).catch(() => null);
if (!raw?.body) return {};
const parsed = JSON.parse(raw.body || '{}');
return asObject(parsed);
} catch (_) {
return {};
}
}
function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap, cacheUpdatedAt }) {
const byRef = new Map();
servers.forEach((s) => {
const refs = [s.id, s.ip, s.dns].filter(Boolean).map((v) => String(v));
refs.forEach((ref) => byRef.set(ref, s));
});
const homeToJh = [];
const jhToExit = [];
for (const iface of tunnelInterfaces) {
if (!iface || !iface.serverId || !iface.serverId2) continue;
const s1 = resolveServerRef(iface.serverId, byRef);
const s2 = resolveServerRef(iface.serverId2, byRef);
if (!s1 || !s2) continue;
const t1 = String(s1.type || '').toLowerCase();
const t2 = String(s2.type || '').toLowerCase();
const k1 = makeServerKey(s1);
const k2 = makeServerKey(s2);
const pkey = pairKey(s1.ip, s2.ip);
const skey = pairKey(k1, k2);
const pingMs = toNumber(pingMap[pkey]);
const speedEntry = asObject(speedMap[skey]);
const downBps = toNumber(speedEntry.tcpDownloadBps);
const upBps = toNumber(speedEntry.tcpUploadBps);
const speedMbps = downBps != null || upBps != null
? Math.max(downBps || 0, upBps || 0) / 1e6
: null;
const scoring = buildSegmentScore({ pingMs, speedMbps, cacheUpdatedAt });
const base = {
interfaceName: iface.name || null,
pingMs,
speedMbps: speedMbps != null ? Number(speedMbps.toFixed(2)) : null,
speedDownloadMbps: downBps != null ? Number((downBps / 1e6).toFixed(2)) : null,
speedUploadMbps: upBps != null ? Number((upBps / 1e6).toFixed(2)) : null,
score: Number(scoring.score.toFixed(4)),
confidence: Number(scoring.confidence.toFixed(4)),
};
if ((t1 === 'home' && t2 === 'jumphost') || (t1 === 'jumphost' && t2 === 'home')) {
const home = t1 === 'home' ? s1 : s2;
const jumphost = t1 === 'jumphost' ? s1 : s2;
homeToJh.push({
id: `${makeServerKey(home)}->${makeServerKey(jumphost)}::${base.interfaceName || 'iface'}`,
homeKey: makeServerKey(home),
jumphostKey: makeServerKey(jumphost),
home: compactServer(home),
jumphost: compactServer(jumphost),
...base,
});
continue;
}
if ((t1 === 'jumphost' && t2 === 'exit') || (t1 === 'exit' && t2 === 'jumphost')) {
const jumphost = t1 === 'jumphost' ? s1 : s2;
const exit = t1 === 'exit' ? s1 : s2;
jhToExit.push({
id: `${makeServerKey(jumphost)}->${makeServerKey(exit)}::${base.interfaceName || 'iface'}`,
jumphostKey: makeServerKey(jumphost),
exitKey: makeServerKey(exit),
jumphost: compactServer(jumphost),
exit: compactServer(exit),
...base,
});
}
}
return { homeToJh, jhToExit };
}
function enrichProbabilities(candidates, scoreField = 'score') {
if (!Array.isArray(candidates) || candidates.length === 0) return [];
const probs = pairProbability(candidates, (c) => c[scoreField]);
return candidates.map((c, i) => ({
...c,
probabilityOptimal: Number(probs[i].toFixed(2)),
}));
}
function groupBy(items, keyGetter) {
const m = new Map();
for (const item of items) {
const key = keyGetter(item);
if (!m.has(key)) m.set(key, []);
m.get(key).push(item);
}
return m;
}
async function getRouteOptimizer(req, res) {
try {
const [servers, networkConfig, networkMapCache] = await Promise.all([
readServersFromS3(),
loadNetworkConfig(),
loadNetworkMapCache(),
]);
const tunnelInterfaces = Array.isArray(networkConfig.tunnelInterfaces)
? networkConfig.tunnelInterfaces
: [];
const { homeToJh, jhToExit } = buildInterfaceCandidates({
servers: Array.isArray(servers) ? servers : [],
tunnelInterfaces,
pingMap: networkMapCache.pingMap,
speedMap: networkMapCache.speedMap,
cacheUpdatedAt: networkMapCache.updatedAt,
});
const homeGroups = groupBy(homeToJh, (x) => x.homeKey);
const exitsByJh = groupBy(jhToExit, (x) => x.jumphostKey);
const homes = [];
for (const [homeKey, hjListRaw] of homeGroups.entries()) {
const hjList = enrichProbabilities(hjListRaw.sort((a, b) => b.score - a.score));
const bestHomeToJumphost = hjList[0] || null;
const fullRoutesRaw = [];
for (const hj of hjList) {
const exitCandidates = exitsByJh.get(hj.jumphostKey) || [];
for (const jhExit of exitCandidates) {
const combinedScore = clamp(hj.score * 0.45 + jhExit.score * 0.55, 0, 1);
fullRoutesRaw.push({
id: `${hj.id}>>>${jhExit.id}`,
home: hj.home,
jumphost: hj.jumphost,
exit: jhExit.exit,
homeToJumphost: {
interfaceName: hj.interfaceName,
pingMs: hj.pingMs,
speedMbps: hj.speedMbps,
score: hj.score,
confidence: hj.confidence,
},
jumphostToExit: {
interfaceName: jhExit.interfaceName,
pingMs: jhExit.pingMs,
speedMbps: jhExit.speedMbps,
score: jhExit.score,
confidence: jhExit.confidence,
},
score: Number(combinedScore.toFixed(4)),
confidence: Number(((hj.confidence + jhExit.confidence) / 2).toFixed(4)),
});
}
}
const fullRoutes = enrichProbabilities(fullRoutesRaw.sort((a, b) => b.score - a.score));
const bestFullRoute = fullRoutes[0] || null;
homes.push({
home: bestHomeToJumphost?.home || null,
bestHomeToJumphost,
bestFullRoute,
homeToJumphostCandidates: hjList,
fullRouteCandidates: fullRoutes,
});
}
// Also expose jumphost->exit view for transparency
const jumphostSummaries = [];
for (const [jumphostKey, exitListRaw] of exitsByJh.entries()) {
const candidates = enrichProbabilities(exitListRaw.sort((a, b) => b.score - a.score));
jumphostSummaries.push({
jumphost: candidates[0]?.jumphost || null,
bestCandidate: candidates[0] || null,
candidates,
});
}
homes.sort((a, b) => {
const pa = a.bestFullRoute?.probabilityOptimal ?? 0;
const pb = b.bestFullRoute?.probabilityOptimal ?? 0;
return pb - pa;
});
return res.json({
ok: true,
generatedAt: Date.now(),
metricsUpdatedAt: networkMapCache.updatedAt || null,
homes,
jumphostToExitByJumphost: jumphostSummaries,
totals: {
homes: homes.length,
homeToJumphostCandidates: homeToJh.length,
jumphostToExitCandidates: jhToExit.length,
},
});
} catch (error) {
console.error('[route-optimizer] getRouteOptimizer:', error);
return sendError(
res,
500,
error?.message || 'Ошибка расчёта оптимального маршрута',
'E_ROUTE_OPTIMIZER'
);
}
}
module.exports = {
getRouteOptimizer,
};
+4
View File
@@ -32,6 +32,7 @@ const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
const trafficRoutes = require('./routes/trafficRoutes');
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
const alertsRoutes = require('./routes/alertsRoutes');
const routeOptimizerRoutes = require('./routes/routeOptimizerRoutes');
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
@@ -497,6 +498,9 @@ app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBack
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
// === LOCAL AI ROUTE OPTIMIZER (rule-based) ===
app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer);
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);