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.
646 lines
20 KiB
TypeScript
646 lines
20 KiB
TypeScript
/**
|
||
* Данные для страницы «Оптимизатор маршрутов» и единый набор параметров 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
|
||
}
|
||
}
|
||
|