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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
|||||||
const trafficRoutes = require('./routes/trafficRoutes');
|
const trafficRoutes = require('./routes/trafficRoutes');
|
||||||
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
|
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
|
||||||
const alertsRoutes = require('./routes/alertsRoutes');
|
const alertsRoutes = require('./routes/alertsRoutes');
|
||||||
|
const routeOptimizerRoutes = require('./routes/routeOptimizerRoutes');
|
||||||
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
|
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
|
||||||
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
|
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
|
||||||
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
||||||
@@ -497,6 +498,9 @@ app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBack
|
|||||||
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
|
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
|
||||||
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
|
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
|
||||||
|
|
||||||
|
// === LOCAL AI ROUTE OPTIMIZER (rule-based) ===
|
||||||
|
app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer);
|
||||||
|
|
||||||
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
|
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
|
||||||
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
|
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
|
||||||
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
|
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import {
|
|||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconClock,
|
IconClock,
|
||||||
IconClockPlay,
|
IconClockPlay,
|
||||||
IconShield
|
IconShield,
|
||||||
|
IconRoute2
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import ServerManager from './ServerManager';
|
import ServerManager from './ServerManager';
|
||||||
import FilterManager from './FilterManager';
|
import FilterManager from './FilterManager';
|
||||||
@@ -51,6 +52,7 @@ import PingServicesManager from './PingServicesManager.jsx';
|
|||||||
import FirewallPage from './FirewallPage.jsx';
|
import FirewallPage from './FirewallPage.jsx';
|
||||||
import SettingsPage from './SettingsPage.jsx';
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
import ResourceStatsPage from './ResourceStatsPage.jsx';
|
import ResourceStatsPage from './ResourceStatsPage.jsx';
|
||||||
|
import RouteOptimizerPage from './RouteOptimizerPage.jsx';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||||
import ToastContainer from './components/ToastContainer.jsx';
|
import ToastContainer from './components/ToastContainer.jsx';
|
||||||
@@ -71,6 +73,7 @@ function LanguageProvider({ children }) {
|
|||||||
ru: {
|
ru: {
|
||||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||||
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||||
|
routeOptimizer: 'Оптимизация маршрутов ИИ',
|
||||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||||
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
|
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
|
||||||
light: 'Светлая', dark: 'Тёмная',
|
light: 'Светлая', dark: 'Тёмная',
|
||||||
@@ -79,6 +82,7 @@ function LanguageProvider({ children }) {
|
|||||||
en: {
|
en: {
|
||||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||||
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||||
|
routeOptimizer: 'AI Route Optimizer',
|
||||||
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
||||||
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall',
|
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall',
|
||||||
light: 'Light', dark: 'Dark',
|
light: 'Light', dark: 'Dark',
|
||||||
@@ -165,7 +169,6 @@ function MainLayout() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layout !== LAYOUT_FLUID) return;
|
if (layout !== LAYOUT_FLUID) return;
|
||||||
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
|
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
|
||||||
const toggler = () => sidebarTogglerRef.current?.click?.();
|
|
||||||
const onShow = () => {
|
const onShow = () => {
|
||||||
if (window.innerWidth < MOBILE_BREAKPOINT) {
|
if (window.innerWidth < MOBILE_BREAKPOINT) {
|
||||||
setSidebarBackdrop(true);
|
setSidebarBackdrop(true);
|
||||||
@@ -252,6 +255,7 @@ function MainLayout() {
|
|||||||
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
|
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
|
||||||
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
|
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
|
||||||
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
|
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
|
||||||
|
{ id: 'route-optimizer', title: t('routeOptimizer'), path: '/route-optimizer', icon: IconRoute2 },
|
||||||
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
|
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -449,6 +453,7 @@ function MainLayout() {
|
|||||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||||
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||||
|
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
|
||||||
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||||
<Route path="/domains" element={<DomainsNewManager />} />
|
<Route path="/domains" element={<DomainsNewManager />} />
|
||||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||||
@@ -625,6 +630,7 @@ function MainLayout() {
|
|||||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||||
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||||
|
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
|
||||||
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||||
<Route path="/domains" element={<DomainsNewManager />} />
|
<Route path="/domains" element={<DomainsNewManager />} />
|
||||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IconRoute2,
|
||||||
|
IconRefresh,
|
||||||
|
IconBrain,
|
||||||
|
IconAlertCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
|
||||||
|
function MetricBadge({ label, value, tone = 'secondary' }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge bg-${tone}-lt text-${tone}`}>
|
||||||
|
{label}: {value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RouteOptimizerPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await api.get('/route-optimizer', { timeout: 30000 });
|
||||||
|
setData(res?.data || null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RouteOptimizerPage] load failed:', e);
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить маршруты');
|
||||||
|
setData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const homes = Array.isArray(data?.homes) ? data.homes : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Локальный ИИ: оптимизатор маршрутов"
|
||||||
|
icon={<IconBrain size={24} />}
|
||||||
|
meta="Rule-based выбор маршрутов: Home → Jumphost → Exit"
|
||||||
|
actions={(
|
||||||
|
<button type="button" className="btn btn-outline-primary" onClick={load} disabled={loading}>
|
||||||
|
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
|
||||||
|
{loading ? 'Обновление...' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger d-flex align-items-center">
|
||||||
|
<IconAlertCircle className="me-2" size={18} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="text-muted py-4">Расчёт маршрутов...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && homes.length === 0 && (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
Нет доступных маршрутов. Проверьте типы серверов (`home`, `jumphost`, `exit`) и `network-config.tunnelInterfaces`.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
{homes.map((entry) => {
|
||||||
|
const best = entry.bestFullRoute;
|
||||||
|
const home = entry.home || {};
|
||||||
|
const routes = Array.isArray(entry.fullRouteCandidates) ? entry.fullRouteCandidates : [];
|
||||||
|
return (
|
||||||
|
<div className="col-12" key={home.id || home.ip || home.dns || Math.random()}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||||
|
<h3 className="card-title mb-0 d-flex align-items-center">
|
||||||
|
<IconRoute2 className="me-2" size={18} />
|
||||||
|
{home.label || home.dns || home.ip || 'Home'}
|
||||||
|
</h3>
|
||||||
|
{best ? (
|
||||||
|
<MetricBadge
|
||||||
|
label="Вероятность оптимальности лучшего маршрута"
|
||||||
|
value={`${best.probabilityOptimal}%`}
|
||||||
|
tone="green"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MetricBadge label="Статус" value="Нет полного маршрута до Exit" tone="orange" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{best ? (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="fw-semibold mb-2">Оптимальный маршрут</div>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<span className="badge bg-blue-lt text-blue">{best.home?.label || 'home'}</span>
|
||||||
|
<span className="text-muted">→</span>
|
||||||
|
<span className="badge bg-indigo-lt text-indigo">{best.jumphost?.label || 'jumphost'}</span>
|
||||||
|
<span className="text-muted">→</span>
|
||||||
|
<span className="badge bg-orange-lt text-orange">{best.exit?.label || 'exit'}</span>
|
||||||
|
<MetricBadge label="Score" value={best.score} tone="primary" />
|
||||||
|
<MetricBadge label="Confidence" value={best.confidence} tone="secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Маршрут</th>
|
||||||
|
<th>Вероятность</th>
|
||||||
|
<th>Home→Jumphost</th>
|
||||||
|
<th>Jumphost→Exit</th>
|
||||||
|
<th>Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{routes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-muted">
|
||||||
|
Полные маршруты до exit-ноды не найдены.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : routes.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>
|
||||||
|
<span className="fw-semibold">{r.home?.label}</span>
|
||||||
|
<span className="text-muted"> → </span>
|
||||||
|
<span className="fw-semibold">{r.jumphost?.label}</span>
|
||||||
|
<span className="text-muted"> → </span>
|
||||||
|
<span className="fw-semibold">{r.exit?.label}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-green-lt text-green">{r.probabilityOptimal}%</span>
|
||||||
|
</td>
|
||||||
|
<td className="small text-muted">
|
||||||
|
ping: {r.homeToJumphost?.pingMs ?? '—'} ms, speed: {r.homeToJumphost?.speedMbps ?? '—'} Mbps
|
||||||
|
</td>
|
||||||
|
<td className="small text-muted">
|
||||||
|
ping: {r.jumphostToExit?.pingMs ?? '—'} ms, speed: {r.jumphostToExit?.speedMbps ?? '—'} Mbps
|
||||||
|
</td>
|
||||||
|
<td className="small">{r.score}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user