Files
MikrotikManager/lib/route-optimizer-data.ts
T
Denozordec 6d8379501c refactor: replace custom API fetch logic with requestJson utility across multiple pages
Updated the API fetching mechanism in various components to utilize the new requestJson function for improved consistency and error handling. This change affects the alerts, dashboard, data collection, filters, gre, network map, probes, recursive routes, route optimizer, servers, settings, traffic, and uptime pages.
2026-05-07 13:35:41 +07:00

646 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Данные для страницы «Оптимизатор маршрутов» и единый набор параметров 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<string, OptimizerApiServer>
} {
const byId = new Map<string, OptimizerApiServer>()
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<FilterRule & { sourceServerId: string }> = []
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<string, RouteOptimizerSpeedProbe | undefined> {
const out = new Map<string, RouteOptimizerSpeedProbe | undefined>()
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<string, RouteOptimizerSpeedProbe> | null = null
const wanList = [...home.wans]
function dfs(i: number, used: Set<string>, cur: Map<string, RouteOptimizerSpeedProbe>, 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<string, RouteOptimizerSpeedProbe> = bestAssign as Map<string, RouteOptimizerSpeedProbe>
for (const wan of home.wans) out.set(wan.id, best.get(wan.id))
return out
}
const used = new Set<string>()
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<string, JhExLeg>()
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
}
}