feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* Данные для страницы «Оптимизатор маршрутов» и единый набор параметров Route AI
|
||||
* (см. блок `#route-ai` в `/settings` и страницу `/route-optimizer`).
|
||||
*/
|
||||
|
||||
import { 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 OptimizerObservedMetrics {
|
||||
probeId: string | null
|
||||
pingMs: number | null
|
||||
lossPct: number | null
|
||||
dlMbps: number | null
|
||||
ulMbps: number | null
|
||||
pingAt: string | null
|
||||
}
|
||||
|
||||
/** DL points toward Home, UL away from Home; TX/RX belong to the btest client. */
|
||||
export function readOptimizerObservedMetrics(probe?: RouteOptimizerSpeedProbe, clientOnFarSide = false): OptimizerObservedMetrics {
|
||||
return {
|
||||
probeId:probe?.id ?? null,pingMs:probe?.lastPingRttMs ?? null,lossPct:probe?.lastPingLossPct ?? null,
|
||||
dlMbps:(clientOnFarSide ? probe?.lastTxAvgMbps : probe?.lastRxAvgMbps) ?? null,
|
||||
ulMbps:(clientOnFarSide ? probe?.lastRxAvgMbps : probe?.lastTxAvgMbps) ?? null,
|
||||
pingAt:probe?.lastPingAt ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export interface WanJhLeg {
|
||||
observed?: OptimizerObservedMetrics
|
||||
wanId: string
|
||||
jhId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
score: number
|
||||
loss: number
|
||||
}
|
||||
|
||||
export interface JhExLeg {
|
||||
observed?: OptimizerObservedMetrics
|
||||
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" | "degraded" | 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 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 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),
|
||||
}))
|
||||
|
||||
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, at?: number): 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(((at ?? 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]
|
||||
const shouldSearch = wanList.length <= 7 && wanList.length <= pool.length
|
||||
// Only the bounded-WAN exact search needs this matrix. A single time
|
||||
// snapshot keeps rankings stable if a calculation crosses an hour boundary.
|
||||
const scored = shouldSearch ? (() => {
|
||||
const at = Date.now()
|
||||
return pool.map(probe => ({ probe, values: wanList.map(wan => scoreProbeForWanIface(probe, wan.iface, at)) }))
|
||||
})() : []
|
||||
|
||||
// A score-first greedy path is a valid leaf of the same forced-pick tree.
|
||||
// Seed only its total, never its assignment: the original DFS must still
|
||||
// visit the first equal leaf, so ties keep the original pool order.
|
||||
if (shouldSearch && scored.every(candidate => candidate.values.every(Number.isFinite))) {
|
||||
const used = new Set<string>()
|
||||
let lower = 0
|
||||
for (let i = 0; i < wanList.length; i++) {
|
||||
let best: (typeof scored)[number] | undefined
|
||||
let maximum = -1
|
||||
for (const candidate of scored) {
|
||||
if (!used.has(candidate.probe.id) && candidate.values[i]! > maximum) {
|
||||
best = candidate
|
||||
maximum = candidate.values[i]!
|
||||
}
|
||||
}
|
||||
if (best) { used.add(best.probe.id); lower += maximum }
|
||||
}
|
||||
bestSum = lower
|
||||
}
|
||||
|
||||
function dfs(i: number, used: Set<string>, cur: Map<string, RouteOptimizerSpeedProbe>, sum: number) {
|
||||
if (i === wanList.length) {
|
||||
if (sum > bestSum || (!bestAssign && sum === bestSum)) {
|
||||
bestSum = sum
|
||||
bestAssign = new Map(cur)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Independent WAN maxima and the highest distinct-probe maxima are
|
||||
// both upper bounds: each completion picks at most one probe per WAN.
|
||||
let upper = sum
|
||||
const candidateMax = new Map<string, number>()
|
||||
for (let next = i; next < wanList.length; next++) {
|
||||
let maximum = 0
|
||||
for (const candidate of scored) {
|
||||
if (used.has(candidate.probe.id)) continue
|
||||
const value = candidate.values[next]!
|
||||
maximum = Math.max(maximum, value)
|
||||
candidateMax.set(candidate.probe.id, Math.max(candidateMax.get(candidate.probe.id) ?? 0, value))
|
||||
}
|
||||
upper += maximum
|
||||
}
|
||||
const remaining = wanList.length - i
|
||||
const top = [...candidateMax.entries()].sort((a, b) => b[1] - a[1]).slice(0, remaining)
|
||||
const topSum = top.reduce((total, [, value]) => total + value, 0)
|
||||
const topById = new Map(top)
|
||||
const distinctUpper = sum + topSum
|
||||
upper = Math.min(upper, distinctUpper)
|
||||
// Equal totals keep the first assignment in the original DFS order.
|
||||
if (upper < bestSum || (bestAssign && upper <= bestSum)) return
|
||||
const wan = wanList[i]!
|
||||
let picked = false
|
||||
for (const candidate of scored) {
|
||||
const p = candidate.probe
|
||||
if (used.has(p.id)) continue
|
||||
const sc = candidate.values[i]!
|
||||
if (sc < 0) continue
|
||||
picked = true
|
||||
// Relax the child using this node's probe maxima. Removing a top
|
||||
// probe leaves K-1 entries; otherwise remove the lowest of the top K.
|
||||
// This avoids rescanning the whole pool for an already losing sibling.
|
||||
const removed = topById.get(p.id) ?? (top.length === remaining ? top.at(-1)![1] : 0)
|
||||
const childUpper = sum + sc + topSum - removed
|
||||
if (childUpper < bestSum || (bestAssign && childUpper <= bestSum)) continue
|
||||
used.add(p.id)
|
||||
cur.set(wan.id, p)
|
||||
dfs(i + 1, used, cur, sum + sc)
|
||||
cur.delete(wan.id)
|
||||
used.delete(p.id)
|
||||
// No remaining sibling can improve a result reaching this node bound.
|
||||
if (bestAssign && bestSum >= upper) return
|
||||
}
|
||||
if (!picked) dfs(i + 1, used, cur, sum)
|
||||
}
|
||||
|
||||
if (shouldSearch) {
|
||||
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 observed = readOptimizerObservedMetrics(probe)
|
||||
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 = observed.dlMbps !== null
|
||||
? Math.max(1, Math.min(wan.maxDl, Math.round(observed.dlMbps)))
|
||||
: modelBw.dl
|
||||
const ul = observed.ulMbps !== null
|
||||
? Math.max(1, Math.min(wan.maxUl, Math.round(observed.ulMbps)))
|
||||
: modelBw.ul
|
||||
const loss = probe?.lastPingLossPct != null
|
||||
? Math.max(0, Math.min(100, Math.round(probe.lastPingLossPct)))
|
||||
: (!homeOnline || !jhOnline ? 100 : 0)
|
||||
wanJhLegs.push({
|
||||
observed,
|
||||
wanId: wan.id,
|
||||
jhId: jh.id,
|
||||
pingMs: ping,
|
||||
dlMbps: dl,
|
||||
ulMbps: ul,
|
||||
loss,
|
||||
score: calcRouteScore(ping, dl, ul, pw),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const wanJhById = new Map<string, Map<string, WanJhLeg>>()
|
||||
for (const leg of wanJhLegs) {
|
||||
let byHost = wanJhById.get(leg.wanId)
|
||||
if (!byHost) { byHost = new Map(); wanJhById.set(leg.wanId, byHost) }
|
||||
// Keep the first match, as the former Array.find did.
|
||||
if (!byHost.has(leg.jhId)) byHost.set(leg.jhId, leg)
|
||||
}
|
||||
|
||||
const jhExMap = new Map<string, JhExLeg>()
|
||||
// Without a configured WAN, no full route can consume a JH→Exit leg.
|
||||
for (const jh of home.wans.length ? 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 observed = readOptimizerObservedMetrics(probe, probe?.srcServerId === 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 = observed.dlMbps !== null
|
||||
? Math.max(1, Math.round(observed.dlMbps))
|
||||
: modelBw.dl
|
||||
const ul = observed.ulMbps !== null
|
||||
? Math.max(1, Math.round(observed.ulMbps))
|
||||
: modelBw.ul
|
||||
jhExMap.set(`${jh.id}::${ex.id}`, {
|
||||
observed,
|
||||
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) {
|
||||
const hw = wanJhById.get(wan.id)?.get(jh.id)
|
||||
if (!hw) continue
|
||||
for (const ex of exitNodes) {
|
||||
const je = jhExMap.get(`${jh.id}::${ex.id}`)
|
||||
if (!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.filter(rule=>rule.sourceServerId===home.id).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 && cur ? rec.prob - cur.prob : null
|
||||
return {
|
||||
community: rule.community,
|
||||
communityName: rule.communityName ?? rule.community,
|
||||
current: cur,
|
||||
recommended: rec,
|
||||
shouldSwitch: diff !== null && 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user