/** * Данные для страницы «Оптимизатор маршрутов» и единый набор параметров Route AI * (см. блок `#route-ai` в `/settings` и страницу `/route-optimizer`). */ import { servers, type FilterRule } from "@/lib/data" // ── Совместимо с app/(main)/route-optimizer/page.tsx ───────────────────────── export interface WanUplink { id: string name: string isp: string iface: string ip: string maxDl: number maxUl: number } export interface HomeRouter { id: string label: string site: string country: string model: string ip: string wans: WanUplink[] } export interface JumpHost { id: string label: string site: string country: string ip: string } export interface ExitNode { id: string label: string site: string country: string ip: string } export interface WanJhLeg { wanId: string jhId: string pingMs: number dlMbps: number ulMbps: number score: number loss: number } export interface JhExLeg { jhId: string exitId: string pingMs: number dlMbps: number ulMbps: number } export interface FullRoute { id: string homeId: string wan: WanUplink jh: JumpHost exit: ExitNode hw: WanJhLeg je: JhExLeg score: number confidence: "HIGH" | "MEDIUM" | "LOW" probabilityOptimal: number } export interface CommRec { community: string communityName: string current: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null recommended: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null shouldSwitch: boolean pinnedBySettings: boolean } export interface HomeEntry { home: HomeRouter wanJhLegs: WanJhLeg[] fullRoutes: FullRoute[] bestRoute: FullRoute | null commRecs: CommRec[] } export interface OptimizerData { updatedAt: string homes: HomeEntry[] } export interface OptimizerSettings { switchThreshold: number hysteresisThreshold: number pingWeight: number probeIntervalMin: number autoApply: boolean autoApplyIntervalMin: number } /** * Значения по умолчанию для Route AI (как в настройках оптимизации маршрутов). * Один источник для `/settings#route-ai` и сброса на `/route-optimizer`. */ export const DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS: OptimizerSettings = { switchThreshold: 15, hysteresisThreshold: 10, pingWeight: 60, probeIntervalMin: 15, autoApply: false, autoApplyIntervalMin: 60, } /** Строка GET /api/servers (фрагмент, нужные поля). */ export interface OptimizerApiServer { id: number name: string host: string type: "jump-host" | "exit-node" | "home-router" site: string country: string enabled: boolean status: "online" | "offline" | null latency: number | null model: string | null wanUplinks?: Array<{ id: string name: string isp: string iface: string ip: string maxDl: number maxUl: number }> } export interface FiltersRulesetRow { serverId: string rules: FilterRule[] } /** Снимок speed-probe из /api/uptime/speed-probes (live-источник ping/speed по интерфейсам). */ export interface RouteOptimizerSpeedProbe { id: string srcServerId: string dstServerId: string srcInterface: string dstInterface: string enabled: boolean lastPingRttMs: number | null lastPingLossPct: number | null lastTxAvgMbps: number | null lastRxAvgMbps: number | null lastPingAt: string | null } /** * Единый расчёт score для Route AI (используется в /route-optimizer и связанных оптимизаторах). * pingWeight = вес ping в процентах (0..100), оставшийся вес идёт в speed. */ export function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) { const pingScore = Math.max(0, 100 - pingMs * 0.6) const speedScore = Math.min(100, (dlMbps + ulMbps) / 18) const w = pingWeight / 100 return Math.round(w * pingScore + (1 - w) * speedScore) } function confidenceFromProb(prob: number): "HIGH" | "MEDIUM" | "LOW" { return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW" } function syntheticWans(home: OptimizerApiServer): WanUplink[] { return [{ id: `w-${home.id}-1`, name: "WAN1", isp: "—", iface: "auto", ip: home.host, maxDl: 1000, maxUl: 1000, }] } function normalizeApiWanUplinks(list: OptimizerApiServer["wanUplinks"]): WanUplink[] { const src = Array.isArray(list) ? list : [] const out = src .map((w, idx) => ({ id: String(w.id || `api-w-${idx + 1}`), name: String(w.name || `WAN${idx + 1}`), isp: String(w.isp || "—"), iface: String(w.iface || "").trim(), ip: String(w.ip || ""), maxDl: Math.max(1, Math.round(Number(w.maxDl) || 1000)), maxUl: Math.max(1, Math.round(Number(w.maxUl) || 1000)), })) .filter((w) => w.iface.length > 0) return out } function catalogWansForHome(homeId: string, homeHost?: string, homeName?: string): WanUplink[] | null { const hostNorm = (homeHost ?? "").trim().toLowerCase() const nameNorm = (homeName ?? "").trim().toLowerCase() const row = servers.find((s) => { if (s.type !== "home-router") return false if (String(s.id) === homeId) return true if (hostNorm && String(s.host ?? "").trim().toLowerCase() === hostNorm) return true if (nameNorm && String(s.name ?? "").trim().toLowerCase() === nameNorm) return true return false }) const list = row?.wanUplinks ?? [] if (!list.length) return null return list.map((w, idx) => ({ id: w.id || `w-${homeId}-${idx + 1}`, name: w.name || `WAN${idx + 1}`, isp: w.isp || "—", iface: w.iface || "", ip: w.ip || "", maxDl: Math.max(1, Math.round(Number(w.maxDl) || 1000)), maxUl: Math.max(1, Math.round(Number(w.maxUl) || 1000)), })) } function legPing( a: number | null, b: number | null, aOk: boolean, bOk: boolean, ): number { if (!aOk || !bOk) return 999 const x = a ?? 70 const y = b ?? 70 return Math.max(1, Math.round((x + y) / 2)) } function legBandwidthMbps(pingMs: number, capDl: number, capUl: number): { dl: number; ul: number } { const factor = Math.max(0.15, 1 - Math.min(pingMs, 200) / 250) return { dl: Math.max(10, Math.round(Math.min(capDl, capDl * factor))), ul: Math.max(10, Math.round(Math.min(capUl, capUl * factor))), } } /** Экспорт для UI: список JH / Exit после фильтрации. */ export function mapApiServersToTopology(rows: OptimizerApiServer[]): { homes: HomeRouter[] jumpHosts: JumpHost[] exitNodes: ExitNode[] byId: Map } { const byId = new Map() for (const s of rows) { if (!s.enabled) continue byId.set(String(s.id), s) } const homes: HomeRouter[] = rows .filter((s) => s.enabled && s.type === "home-router") .map((s) => ({ id: String(s.id), label: s.name || s.host, site: s.site || "—", country: s.country || "UN", model: s.model ?? "—", ip: s.host, wans: normalizeApiWanUplinks(s.wanUplinks).length > 0 ? normalizeApiWanUplinks(s.wanUplinks) : (catalogWansForHome(String(s.id), s.host, s.name) ?? syntheticWans(s)), })) const jumpHosts: JumpHost[] = rows .filter((s) => s.enabled && s.type === "jump-host" && s.status !== "offline") .map((s) => ({ id: String(s.id), label: s.name || s.host, site: s.site || "—", country: s.country || "UN", ip: s.host, })) const exitNodes: ExitNode[] = rows .filter((s) => s.enabled && s.type === "exit-node" && s.status !== "offline") .map((s) => ({ id: String(s.id), label: s.name || s.host, site: s.site || "—", country: s.country || "UN", ip: s.host, })) return { homes, jumpHosts, exitNodes, byId } } /** * Строит OptimizerData из живых серверов (latency/status из опроса), * без случайного jitter — детерминированный ранг маршрутов. */ export function buildLiveOptimizerData( rows: OptimizerApiServer[], rulesets: FiltersRulesetRow[] | null, settings: OptimizerSettings, speedProbes: RouteOptimizerSpeedProbe[] = [], ): OptimizerData { const { homes, jumpHosts, exitNodes, byId } = mapApiServersToTopology(rows) const pw = settings.pingWeight const flatRules: Array = [] if (rulesets) { for (const rs of rulesets) { for (const r of rs.rules ?? []) { flatRules.push({ ...r, sourceServerId: rs.serverId }) } } } const homesOut: HomeEntry[] = homes.map((home) => { const homeRow = byId.get(home.id) const homeOnline = homeRow?.status === "online" const homeLat = homeRow?.latency ?? null const wanJhLegs: WanJhLeg[] = [] const probesForHome = speedProbes .filter((p) => p.enabled !== false && p.srcServerId === home.id) function scoreProbeForWanIface(p: RouteOptimizerSpeedProbe, wanIface: string): number { const want = wanIface.trim().toLowerCase() const got = String(p.srcInterface ?? "").trim().toLowerCase() if (got && want && got !== want) return -1 const ifaceScore = got && want && got === want ? 100 : (!got && want) ? 35 : (!want && got) ? 15 : 5 const freshness = p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0 return ifaceScore + (p.lastPingRttMs != null ? 20 : 0) + (p.lastTxAvgMbps != null ? 10 : 0) + (p.lastRxAvgMbps != null ? 10 : 0) + freshness } function assignProbesForWanJh(jhId: string): Map { const out = new Map() const pool = probesForHome.filter((p) => p.dstServerId === jhId) if (!pool.length) { for (const wan of home.wans) out.set(wan.id, undefined) return out } let bestSum = -Infinity let bestAssign: Map | null = null const wanList = [...home.wans] function dfs(i: number, used: Set, cur: Map, sum: number) { if (i === wanList.length) { if (sum > bestSum) { bestSum = sum bestAssign = new Map(cur) } return } const wan = wanList[i]! let picked = false for (const p of pool) { if (used.has(p.id)) continue const sc = scoreProbeForWanIface(p, wan.iface) if (sc < 0) continue picked = true used.add(p.id) cur.set(wan.id, p) dfs(i + 1, used, cur, sum + sc) cur.delete(wan.id) used.delete(p.id) } if (!picked) dfs(i + 1, used, cur, sum) } if (wanList.length <= 7 && wanList.length <= pool.length) { dfs(0, new Set(), new Map(), 0) } if (bestAssign) { const best: Map = bestAssign as Map for (const wan of home.wans) out.set(wan.id, best.get(wan.id)) return out } const used = new Set() for (const wan of home.wans) { let best: RouteOptimizerSpeedProbe | undefined let bestScore = -1 for (const p of pool) { if (used.has(p.id)) continue const sc = scoreProbeForWanIface(p, wan.iface) if (sc > bestScore) { bestScore = sc best = p } } if (best && bestScore >= 0) { out.set(wan.id, best) used.add(best.id) } else { out.set(wan.id, undefined) } } return out } function pickProbeForJhExit(jhId: string, exitId: string): RouteOptimizerSpeedProbe | undefined { const pool = speedProbes.filter((p) => { if (p.enabled === false) return false const a = p.srcServerId === jhId && p.dstServerId === exitId const b = p.srcServerId === exitId && p.dstServerId === jhId return a || b }) if (!pool.length) return undefined let best: RouteOptimizerSpeedProbe | undefined let bestScore = -1 for (const p of pool) { const forward = p.srcServerId === jhId && p.dstServerId === exitId const freshness = p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0 const score = (forward ? 5 : 0) + (p.lastPingRttMs != null ? 25 : 0) + (p.lastTxAvgMbps != null ? 12 : 0) + (p.lastRxAvgMbps != null ? 12 : 0) + freshness if (score > bestScore) { bestScore = score best = p } } return best } for (const jh of jumpHosts) { const assignedByWanId = assignProbesForWanJh(jh.id) for (const wan of home.wans) { const jhRow = byId.get(jh.id) const jhOnline = jhRow?.status === "online" const probe = assignedByWanId.get(wan.id) const modelPing = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline) const ping = probe?.lastPingRttMs != null ? Math.max(1, Math.round(probe.lastPingRttMs)) : modelPing const modelBw = legBandwidthMbps(ping, wan.maxDl, wan.maxUl) const dl = probe?.lastTxAvgMbps != null ? Math.max(1, Math.min(wan.maxDl, Math.round(probe.lastTxAvgMbps))) : modelBw.dl const ul = probe?.lastRxAvgMbps != null ? Math.max(1, Math.min(wan.maxUl, Math.round(probe.lastRxAvgMbps))) : modelBw.ul const loss = probe?.lastPingLossPct != null ? Math.max(0, Math.min(100, Math.round(probe.lastPingLossPct))) : (!homeOnline || !jhOnline ? 100 : 0) wanJhLegs.push({ wanId: wan.id, jhId: jh.id, pingMs: ping, dlMbps: dl, ulMbps: ul, loss, score: calcRouteScore(ping, dl, ul, pw), }) } } const jhExMap = new Map() for (const jh of jumpHosts) { for (const ex of exitNodes) { const jhRow = byId.get(jh.id) const exRow = byId.get(ex.id) const ok = jhRow?.status === "online" && exRow?.status === "online" const probe = pickProbeForJhExit(jh.id, ex.id) const modelPing = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok) const ping = probe?.lastPingRttMs != null ? Math.max(1, Math.round(probe.lastPingRttMs)) : modelPing const modelBw = legBandwidthMbps(ping, 1000, 1000) const dl = probe?.lastTxAvgMbps != null ? Math.max(1, Math.round(probe.lastTxAvgMbps)) : modelBw.dl const ul = probe?.lastRxAvgMbps != null ? Math.max(1, Math.round(probe.lastRxAvgMbps)) : modelBw.ul jhExMap.set(`${jh.id}::${ex.id}`, { jhId: jh.id, exitId: ex.id, pingMs: ping, dlMbps: dl, ulMbps: ul, }) } } const fullRoutes: FullRoute[] = [] for (const wan of home.wans) { for (const jh of jumpHosts) { for (const ex of exitNodes) { const hw = wanJhLegs.find((l) => l.wanId === wan.id && l.jhId === jh.id) const je = jhExMap.get(`${jh.id}::${ex.id}`) if (!hw || !je) continue const totalPing = hw.pingMs + je.pingMs const dl = Math.min(hw.dlMbps, je.dlMbps) const ul = Math.min(hw.ulMbps, je.ulMbps) const score = calcRouteScore(totalPing, dl, ul, pw) fullRoutes.push({ id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`, homeId: home.id, wan, jh, exit: ex, hw, je, score, confidence: confidenceFromProb(score), probabilityOptimal: 0, }) } } } const sorted = [...fullRoutes].sort((a, b) => b.score - a.score) const topScore = sorted[0]?.score ?? 1 sorted.forEach((r, i) => { r.probabilityOptimal = Math.max(1, Math.round(topScore * (0.92 ** i))) }) sorted.sort((a, b) => b.probabilityOptimal - a.probabilityOptimal) const best = sorted[0] ?? null const commRecs: CommRec[] = flatRules.slice(0, 24).map((rule) => { const curProb = 38 + (rule.community.length % 12) const recProb = best ? Math.min(92, curProb + 18 + (best.score % 10)) : curProb const rec = best ? { wan: best.wan.name, jh: best.jh.label, exit: best.exit.label, gateway: rule.gateway || "—", prob: recProb, } : null const cur = best ? { wan: home.wans[0]?.name ?? "WAN1", jh: jumpHosts[0]?.label ?? "—", exit: exitNodes[0]?.label ?? "—", gateway: rule.gateway || "—", prob: curProb, } : null const diff = (rec?.prob ?? 0) - (cur?.prob ?? 0) return { community: rule.community, communityName: rule.communityName ?? rule.community, current: cur, recommended: rec, shouldSwitch: diff >= settings.switchThreshold, pinnedBySettings: false, } }) return { home, wanJhLegs, fullRoutes: sorted, bestRoute: best, commRecs, } }) return { updatedAt: new Date().toLocaleTimeString("ru-RU"), homes: homesOut, } } /** Частичные настройки из внешнего сервиса (Router Lists route-ai и т.п.). */ export type RouteAiRemotePatch = Partial<{ switchThreshold: number hysteresisThreshold: number pingWeight: number probeIntervalMin: number autoApply: boolean autoApplyIntervalMin: number }> function clamp(n: number, lo: number, hi: number) { return Math.min(hi, Math.max(lo, n)) } export function mergeOptimizerSettings( base: OptimizerSettings, patch: RouteAiRemotePatch, ): OptimizerSettings { const next = { ...base } if (typeof patch.switchThreshold === "number") { next.switchThreshold = clamp(Math.round(patch.switchThreshold), 0, 50) } if (typeof patch.hysteresisThreshold === "number") { next.hysteresisThreshold = clamp(Math.round(patch.hysteresisThreshold), 0, 50) } if (typeof patch.pingWeight === "number") { next.pingWeight = clamp(Math.round(patch.pingWeight), 0, 100) } if (typeof patch.probeIntervalMin === "number") { next.probeIntervalMin = clamp(Math.round(patch.probeIntervalMin), 1, 240) } if (typeof patch.autoApply === "boolean") next.autoApply = patch.autoApply if (typeof patch.autoApplyIntervalMin === "number") { next.autoApplyIntervalMin = clamp(Math.round(patch.autoApplyIntervalMin), 5, 1440) } return next } /** localStorage: настройки блока «Настройки оптимизатора» на `/route-optimizer`. */ export const ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY = "routerlists:route-optimizer-settings" /** Читает сохранённые пороги/веса (только в браузере). */ export function readStoredRouteOptimizerSettings(): OptimizerSettings { if (typeof window === "undefined") return DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS try { const raw = localStorage.getItem(ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY) if (!raw) return DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS const p = JSON.parse(raw) as unknown if (typeof p !== "object" || p === null) return DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS return mergeOptimizerSettings( DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS, p as RouteAiRemotePatch, ) } catch { return DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } }