435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
/**
|
||
* Данные для страницы «Оптимизатор маршрутов» и единый набор параметров 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 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
|
||
}
|
||
|
||
export interface FiltersRulesetRow {
|
||
serverId: string
|
||
rules: FilterRule[]
|
||
}
|
||
|
||
function calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) {
|
||
const pingScore = Math.max(0, 100 - pingMs * 0.6)
|
||
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
|
||
const w = pw / 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 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: 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,
|
||
): 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[] = []
|
||
for (const wan of home.wans) {
|
||
for (const jh of jumpHosts) {
|
||
const jhRow = byId.get(jh.id)
|
||
const jhOnline = jhRow?.status === "online"
|
||
const ping = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline)
|
||
const { dl, ul } = legBandwidthMbps(ping, wan.maxDl, wan.maxUl)
|
||
const loss = !homeOnline || !jhOnline ? 100 : 0
|
||
wanJhLegs.push({
|
||
wanId: wan.id,
|
||
jhId: jh.id,
|
||
pingMs: ping,
|
||
dlMbps: dl,
|
||
ulMbps: ul,
|
||
loss,
|
||
score: calcScore(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 ping = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok)
|
||
const { dl, ul } = legBandwidthMbps(ping, 1000, 1000)
|
||
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 = calcScore(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
|
||
}
|
||
}
|
||
|