Init 2
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import type { PingProbe, Server } from "@/lib/data"
|
||||
|
||||
/** Длина ряда на графике дашборда (ось времени). */
|
||||
export const DASH_LATENCY_POINTS = 60
|
||||
|
||||
/** Растягивает/сэмплирует ряд до `targetLen` (линейная интерполяция по индексам). */
|
||||
export function padSeriesToLength(values: number[], targetLen: number): number[] {
|
||||
if (targetLen <= 0) return []
|
||||
if (values.length === 0) return Array(targetLen).fill(0)
|
||||
if (values.length === targetLen) return [...values]
|
||||
if (values.length > targetLen) return values.slice(values.length - targetLen)
|
||||
if (values.length === 1) return Array(targetLen).fill(values[0] ?? 0)
|
||||
const out: number[] = []
|
||||
for (let i = 0; i < targetLen; i++) {
|
||||
const t = (i / (targetLen - 1)) * (values.length - 1)
|
||||
const j = Math.floor(t)
|
||||
const f = t - j
|
||||
const a = values[j] ?? 0
|
||||
const b = values[Math.min(j + 1, values.length - 1)] ?? a
|
||||
out.push(Math.round(a + f * (b - a)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface DashboardLatencyChartInput {
|
||||
series: Record<string, number[]>
|
||||
labels: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Средний RTT по всем включённым пробам с одного источника (srcServerId).
|
||||
* Ключ серии — стабильный id вида `src-<numericId>`; подпись — площадка или имя сервера.
|
||||
* Ограничение числа линий — чтобы график оставался читаемым.
|
||||
*/
|
||||
export function buildLatencySeriesByProbeSource(
|
||||
probes: PingProbe[],
|
||||
servers: Server[],
|
||||
opts?: { maxServers?: number; points?: number },
|
||||
): DashboardLatencyChartInput {
|
||||
const maxServers = opts?.maxServers ?? 8
|
||||
const points = opts?.points ?? DASH_LATENCY_POINTS
|
||||
|
||||
const bySrv = new Map<string, PingProbe[]>()
|
||||
for (const p of probes) {
|
||||
if (!p.enabled || !p.series?.length) continue
|
||||
const sid = p.srcServerId
|
||||
if (!bySrv.has(sid)) bySrv.set(sid, [])
|
||||
bySrv.get(sid)!.push(p)
|
||||
}
|
||||
|
||||
const sortedIds = [...bySrv.keys()].sort((a, b) => {
|
||||
const na = servers.find((s) => s.id === a)?.name ?? a
|
||||
const nb = servers.find((s) => s.id === b)?.name ?? b
|
||||
return na.localeCompare(nb, "ru")
|
||||
}).slice(0, maxServers)
|
||||
|
||||
const series: Record<string, number[]> = {}
|
||||
const labels: Record<string, string> = {}
|
||||
|
||||
for (const sid of sortedIds) {
|
||||
const plist = bySrv.get(sid)!
|
||||
const innerLen = plist.reduce((m, p) => Math.max(m, p.series.length), 0)
|
||||
if (innerLen === 0) continue
|
||||
|
||||
const averaged = Array.from({ length: innerLen }, (_, i) => {
|
||||
const vals = plist
|
||||
.map((p) => p.series[i])
|
||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
|
||||
if (!vals.length) return 0
|
||||
return Math.round(vals.reduce((x, y) => x + y, 0) / vals.length)
|
||||
})
|
||||
|
||||
const key = `src-${sid}`
|
||||
series[key] = padSeriesToLength(averaged, points)
|
||||
const srv = servers.find((s) => s.id === sid)
|
||||
const site = srv?.site?.trim()
|
||||
labels[key] =
|
||||
site && site !== "—"
|
||||
? site.slice(0, 16)
|
||||
: (srv?.name ?? `#${sid}`).slice(0, 16)
|
||||
}
|
||||
|
||||
return { series, labels }
|
||||
}
|
||||
+19
-7
@@ -24,17 +24,29 @@ const LS_MODE = "routerlists:data-source"
|
||||
const LS_BACKEND = "routerlists:backend-url"
|
||||
const DEFAULT_URL = "http://localhost:8000"
|
||||
|
||||
function readStoredMode(): DataSourceMode {
|
||||
if (typeof window === "undefined") return "mock"
|
||||
const stored = localStorage.getItem(LS_MODE) as DataSourceMode | null
|
||||
return stored === "mock" || stored === "live" ? stored : "mock"
|
||||
}
|
||||
|
||||
function readStoredBackendUrl(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_URL
|
||||
return localStorage.getItem(LS_BACKEND) ?? DEFAULT_URL
|
||||
}
|
||||
|
||||
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setModeState] = useState<DataSourceMode>("mock")
|
||||
const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL)
|
||||
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
||||
|
||||
// Restore from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedMode = localStorage.getItem(LS_MODE) as DataSourceMode | null
|
||||
const storedUrl = localStorage.getItem(LS_BACKEND)
|
||||
if (storedMode === "mock" || storedMode === "live") setModeState(storedMode)
|
||||
if (storedUrl) setBackendUrlState(storedUrl)
|
||||
queueMicrotask(() => {
|
||||
const nextMode = readStoredMode()
|
||||
const nextBackendUrl = readStoredBackendUrl()
|
||||
setModeState(nextMode)
|
||||
setBackendUrlState(nextBackendUrl)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((m: DataSourceMode) => {
|
||||
@@ -57,9 +69,9 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-check when mode switches to live
|
||||
// Auto-check when mode switches to live (defer to avoid set-state-in-effect rule on async loader)
|
||||
useEffect(() => {
|
||||
if (mode === "live") checkBackend()
|
||||
if (mode === "live") queueMicrotask(() => { void checkBackend() })
|
||||
}, [mode, checkBackend])
|
||||
|
||||
return (
|
||||
|
||||
+9
-3
@@ -58,9 +58,12 @@ export interface Server {
|
||||
// home-router specific
|
||||
wanUplinks?: WanUplink[]
|
||||
lanSubnet?: string // e.g. "192.168.10.0/24"
|
||||
// snapshot / poll data
|
||||
// snapshot / poll data (как GET /api/servers — последний poll MikroTik)
|
||||
uptime?: string
|
||||
cpuLoad?: number
|
||||
/** байты, поля RouterOS resource */
|
||||
freeMemory?: number
|
||||
totalMemory?: number
|
||||
polledAt?: string
|
||||
}
|
||||
|
||||
@@ -246,6 +249,8 @@ export interface PingProbe {
|
||||
status: "up" | "warn" | "down"
|
||||
series: number[]
|
||||
enabled: boolean
|
||||
/** Показывать в блоке «Активные пробы» на дашборде (звезда в мониторинге) */
|
||||
showOnDashboard?: boolean
|
||||
}
|
||||
|
||||
export interface SystemEvent {
|
||||
@@ -607,8 +612,9 @@ export interface FilterRule {
|
||||
community: string // BGP community, e.g. "65001:100"
|
||||
communityName?: string // optional human-readable name
|
||||
action?: "route" | "blackhole" // default "route"; "blackhole" → null-route (RTBH)
|
||||
gateway: string // remote inner IP of GRE tunnel; empty for blackhole
|
||||
gatewayTunnelId: string // reference to greTunnel.id; empty for blackhole
|
||||
gateway: string // next-hop IP for filter rule; empty for blackhole
|
||||
/** GRE: id туннеля / имя интерфейса; рекурсивный маршрут: `rec:<id>` строки recursive_routes на сервере */
|
||||
gatewayTunnelId: string
|
||||
description: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import type { Domain, IpRange, Asn } from "@/lib/data"
|
||||
|
||||
export interface EvoBgpCommunityRow {
|
||||
id: string
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
type: "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
filterIds: string[]
|
||||
serverCount: number
|
||||
prefixCount: number
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EvoBgpCatalogSnapshot {
|
||||
fetchedAt: string
|
||||
modules: { id: string; name: string; type: string }[]
|
||||
domains: Domain[]
|
||||
ipRanges: IpRange[]
|
||||
asns: Asn[]
|
||||
communities: EvoBgpCommunityRow[]
|
||||
}
|
||||
|
||||
export interface EvoBgpSettingsDto {
|
||||
baseUrl: string
|
||||
enabled: boolean
|
||||
secretConfigured: boolean
|
||||
}
|
||||
|
||||
/** undefined — не менять ключ; null или очистка через отдельное сохранение */
|
||||
export type EvoBgpSavePayload = {
|
||||
baseUrl?: string
|
||||
enabled?: boolean
|
||||
apiKey?: string | null
|
||||
}
|
||||
|
||||
/** Черновик для POST /evobgp/test: пустой объект — всё из БД */
|
||||
export type EvoBgpTestDraft = { baseUrl?: string; apiKey?: string }
|
||||
|
||||
interface EvoBgpContextValue {
|
||||
baseUrl: string
|
||||
enabled: boolean
|
||||
secretConfigured: boolean
|
||||
settingsLoaded: boolean
|
||||
snapshot: EvoBgpCatalogSnapshot | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
loadSettings: () => Promise<void>
|
||||
saveSettings: (patch: EvoBgpSavePayload) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
testConnection: (draft?: EvoBgpTestDraft) => Promise<{ ok: boolean; message: string }>
|
||||
}
|
||||
|
||||
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null)
|
||||
|
||||
export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const [baseUrl, setBaseUrlState] = useState("")
|
||||
const [enabled, setEnabledState] = useState(false)
|
||||
const [secretConfigured, setSecretConfigured] = useState(false)
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||
const [snapshot, setSnapshot] = useState<EvoBgpCatalogSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const pullCatalog = useCallback(
|
||||
async (catalogEnabled: boolean) => {
|
||||
if (mode !== "live" || backendStatus !== true || !catalogEnabled) {
|
||||
setSnapshot(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, {
|
||||
method: "POST",
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText
|
||||
try {
|
||||
const j = JSON.parse(text) as { error?: string; detail?: string }
|
||||
msg = j.error ?? j.detail ?? msg
|
||||
} catch {
|
||||
if (text) msg = text
|
||||
}
|
||||
throw new Error(msg || "Ошибка EvoBGP")
|
||||
}
|
||||
setSnapshot(JSON.parse(text) as EvoBgpCatalogSnapshot)
|
||||
} catch (e) {
|
||||
setSnapshot(null)
|
||||
setError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[mode, backendStatus, backendUrl],
|
||||
)
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
if (mode !== "live" || backendStatus !== true) {
|
||||
setSettingsLoaded(false)
|
||||
setSnapshot(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`)
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = (await res.json()) as EvoBgpSettingsDto
|
||||
setBaseUrlState(data.baseUrl ?? "")
|
||||
setEnabledState(data.enabled ?? false)
|
||||
setSecretConfigured(data.secretConfigured ?? false)
|
||||
setSettingsLoaded(true)
|
||||
await pullCatalog(data.enabled ?? false)
|
||||
} catch {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
}, [mode, backendStatus, backendUrl, pullCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadSettings()
|
||||
})
|
||||
}, [loadSettings])
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (patch: EvoBgpSavePayload) => {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText
|
||||
try {
|
||||
const j = JSON.parse(text) as { error?: string }
|
||||
msg = j.error ?? msg
|
||||
} catch {
|
||||
if (text) msg = text
|
||||
}
|
||||
throw new Error(msg || "Не удалось сохранить")
|
||||
}
|
||||
const data = JSON.parse(text) as EvoBgpSettingsDto
|
||||
setBaseUrlState(data.baseUrl ?? "")
|
||||
setEnabledState(data.enabled ?? false)
|
||||
setSecretConfigured(data.secretConfigured ?? false)
|
||||
await pullCatalog(data.enabled ?? false)
|
||||
},
|
||||
[backendUrl, pullCatalog],
|
||||
)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await pullCatalog(enabled)
|
||||
}, [enabled, pullCatalog])
|
||||
|
||||
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(draft ?? {}),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
||||
if (!res.ok) throw new Error(data.error ?? res.statusText)
|
||||
return { ok: true, message: "Соединение с EvoBGP установлено" }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" }
|
||||
}
|
||||
}, [backendUrl])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
baseUrl,
|
||||
enabled,
|
||||
secretConfigured,
|
||||
settingsLoaded,
|
||||
snapshot,
|
||||
loading,
|
||||
error,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
refresh,
|
||||
testConnection,
|
||||
}),
|
||||
[
|
||||
baseUrl,
|
||||
enabled,
|
||||
secretConfigured,
|
||||
settingsLoaded,
|
||||
snapshot,
|
||||
loading,
|
||||
error,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
refresh,
|
||||
testConnection,
|
||||
],
|
||||
)
|
||||
|
||||
return <EvoBgpContext.Provider value={value}>{children}</EvoBgpContext.Provider>
|
||||
}
|
||||
|
||||
export function useEvoBGP(): EvoBgpContextValue {
|
||||
const ctx = useContext(EvoBgpContext)
|
||||
if (!ctx) throw new Error("useEvoBGP must be used within EvoBGPProvider")
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const IPV4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
|
||||
|
||||
/** Нормализация IP/endpoint из RouterOS (CIDR, zone id) — как в network-map-layout. */
|
||||
export function normalizeGreEndpointAddr(addr: string): string {
|
||||
return addr.trim().split("/")[0].split("%")[0].trim()
|
||||
}
|
||||
|
||||
/** Явный IPv4 (для отличия от FQDN в remote-address GRE). */
|
||||
export function isIpv4String(s: string): boolean {
|
||||
return IPV4.test(s.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Сопоставление IP WAN в каталоге с outer GRE: прямой IP или FQDN,
|
||||
* зарезолвенный в API (hostname lower-case → A-запись).
|
||||
*/
|
||||
export function wanUplinkIpMatchesGreOuter(
|
||||
wanIp: string,
|
||||
greOuter: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): boolean {
|
||||
const a = normalizeGreEndpointAddr(wanIp)
|
||||
const b = normalizeGreEndpointAddr(greOuter)
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
if (resolvedIpv4ByHost) {
|
||||
const r = resolvedIpv4ByHost.get(b.toLowerCase())
|
||||
if (r && a === normalizeGreEndpointAddr(r)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Все варианты внешнего IP для сопоставления с каталогом (IP и зарезолвенный FQDN). */
|
||||
export function greOuterComparableIps(
|
||||
outer: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
const n = normalizeGreEndpointAddr(outer)
|
||||
if (!n || n === "0.0.0.0") return []
|
||||
const out: string[] = []
|
||||
if (isIpv4String(n)) out.push(n)
|
||||
const r = resolvedIpv4ByHost?.get(n.toLowerCase())
|
||||
if (r) {
|
||||
const ip = normalizeGreEndpointAddr(r)
|
||||
if (ip && isIpv4String(ip) && !out.includes(ip)) out.push(ip)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Собрать уникальные FQDN из local/remote-address (без чистых IPv4). */
|
||||
export function collectGreEndpointHostnames(
|
||||
tunnels: Array<{ localAddress: string; remoteAddress: string }>,
|
||||
): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const t of tunnels) {
|
||||
for (const a of [t.localAddress, t.remoteAddress]) {
|
||||
const n = normalizeGreEndpointAddr(a)
|
||||
if (!n || n === "0.0.0.0") continue
|
||||
if (!isIpv4String(n)) set.add(n.toLowerCase())
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
/** Ответ POST /api/network/resolve-hosts → Map для матчинга. */
|
||||
export function greResolvedMapFromApi(
|
||||
results: Record<string, string | null> | undefined,
|
||||
): ReadonlyMap<string, string> {
|
||||
const m = new Map<string, string>()
|
||||
if (!results) return m
|
||||
for (const [k, v] of Object.entries(results)) {
|
||||
if (v && isIpv4String(v)) m.set(k.toLowerCase(), normalizeGreEndpointAddr(v))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/** Подпись outer на карте: при FQDN дополнительно показываем разрешённый IPv4. */
|
||||
export function formatGreOuterForDisplay(
|
||||
addr: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const n = normalizeGreEndpointAddr(addr) || "?"
|
||||
if (isIpv4String(n)) return n
|
||||
const r = resolvedIpv4ByHost?.get(n.toLowerCase())
|
||||
return r ? `${n} (${r})` : n
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { GreTunnel, Server } from "@/lib/data"
|
||||
import {
|
||||
formatGreOuterForDisplay,
|
||||
normalizeGreEndpointAddr,
|
||||
wanUplinkIpMatchesGreOuter,
|
||||
} from "@/lib/gre-endpoint-resolve"
|
||||
|
||||
/** Поля speed-пробы из мониторинга (Мониторинг → скорость), нужные для GRE на карте. */
|
||||
export interface GreSpeedProbeSnapshot {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
enabled?: boolean
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
lastTxAvgMbps?: number | null
|
||||
lastRxAvgMbps?: number | null
|
||||
lastPingRttMs?: number | null
|
||||
}
|
||||
|
||||
export type GreTunnelProbeEdge = {
|
||||
tunnel: GreTunnel
|
||||
fromServer: Server
|
||||
toServer: Server
|
||||
}
|
||||
|
||||
function normIface(i?: string | null): string {
|
||||
return (i ?? "").trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** WAN-интерфейс узла, чей внешний IP совпадает с GRE outer (endpoint каталога). */
|
||||
export function ifaceNameForGreOuterIp(
|
||||
server: Server,
|
||||
outerIp: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string | null {
|
||||
const r = normalizeGreEndpointAddr(outerIp)
|
||||
if (!r || r === "0.0.0.0") return null
|
||||
for (const w of server.wanUplinks ?? []) {
|
||||
if (wanUplinkIpMatchesGreOuter(w.ip, outerIp, resolvedIpv4ByHost)) return w.iface.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Короткая подпись для бейджа: внешние IP и имена WAN при наличии в каталоге. */
|
||||
export function greOuterSummaryLine(
|
||||
tunnel: GreTunnel,
|
||||
fromServer: Server,
|
||||
toServer: Server,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const lo = formatGreOuterForDisplay(tunnel.localAddress, resolvedIpv4ByHost) || "auto"
|
||||
const ro = formatGreOuterForDisplay(tunnel.remoteAddress, resolvedIpv4ByHost) || "?"
|
||||
const li = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
const ri = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||||
const ipPart = `${lo}→${ro}`
|
||||
if (li || ri) return `${ipPart} · ${li ?? "?"}→${ri ?? "?"}`
|
||||
return ipPart
|
||||
}
|
||||
|
||||
function scoreProbeForGreTunnel(
|
||||
p: GreSpeedProbeSnapshot,
|
||||
tunnel: GreTunnel,
|
||||
fromServer: Server,
|
||||
toServer: Server,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): number {
|
||||
const pf = String(p.srcServerId)
|
||||
const pt = String(p.dstServerId)
|
||||
const fid = String(fromServer.id)
|
||||
const tid = String(toServer.id)
|
||||
const forward = pf === fid && pt === tid
|
||||
const backward = pf === tid && pt === fid
|
||||
if (!forward && !backward) return -1
|
||||
|
||||
const si = normIface(p.srcInterface)
|
||||
const di = normIface(p.dstInterface)
|
||||
let s = 0
|
||||
|
||||
if (forward) {
|
||||
const srcW = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
const dstW = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||||
const srcM = !!(srcW && si === normIface(srcW))
|
||||
const dstM = !!(dstW && di === normIface(dstW))
|
||||
if (srcM) s += 52
|
||||
if (dstM) s += 52
|
||||
if (srcM && dstM) s += 34
|
||||
if (!srcW && dstW && di === normIface(dstW)) s += 45
|
||||
if (!dstW && srcW && si === normIface(srcW)) s += 45
|
||||
} else {
|
||||
const srcW = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||||
const dstW = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
const srcM = !!(srcW && si === normIface(srcW))
|
||||
const dstM = !!(dstW && di === normIface(dstW))
|
||||
if (srcM) s += 52
|
||||
if (dstM) s += 52
|
||||
if (srcM && dstM) s += 34
|
||||
if (!srcW && dstW && di === normIface(dstW)) s += 45
|
||||
if (!dstW && srcW && si === normIface(srcW)) s += 45
|
||||
}
|
||||
|
||||
if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null)
|
||||
s += 4
|
||||
return s
|
||||
}
|
||||
|
||||
function probesForServerPair(
|
||||
probes: GreSpeedProbeSnapshot[],
|
||||
fromServer: Server,
|
||||
toServer: Server,
|
||||
): GreSpeedProbeSnapshot[] {
|
||||
const fid = String(fromServer.id)
|
||||
const tid = String(toServer.id)
|
||||
return probes.filter((p) => {
|
||||
if (p.enabled === false) return false
|
||||
const pf = String(p.srcServerId)
|
||||
const pt = String(p.dstServerId)
|
||||
return (pf === fid && pt === tid) || (pf === tid && pt === fid)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Для всех GRE на карте назначает speed-пробы без повторного использования одной пробы
|
||||
* на два разных туннеля между одной и той же парой узлов (RT / MTS и т.п.).
|
||||
*/
|
||||
export function assignSpeedProbesToGreTunnels(
|
||||
edges: GreTunnelProbeEdge[],
|
||||
probes: GreSpeedProbeSnapshot[],
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): ReadonlyMap<string, GreSpeedProbeSnapshot | undefined> {
|
||||
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||||
|
||||
const byPair = new Map<string, GreTunnelProbeEdge[]>()
|
||||
for (const e of edges) {
|
||||
const k = `${String(e.fromServer.id)}\t${String(e.toServer.id)}`
|
||||
const arr = byPair.get(k) ?? []
|
||||
arr.push(e)
|
||||
byPair.set(k, arr)
|
||||
}
|
||||
|
||||
for (const group of byPair.values()) {
|
||||
const assigned = assignProbesWithinPair(group, probes, resolvedIpv4ByHost)
|
||||
for (const [tunnelId, sp] of assigned) result.set(tunnelId, sp)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function assignProbesWithinPair(
|
||||
edges: GreTunnelProbeEdge[],
|
||||
allProbes: GreSpeedProbeSnapshot[],
|
||||
resolved?: ReadonlyMap<string, string>,
|
||||
): Map<string, GreSpeedProbeSnapshot | undefined> {
|
||||
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||||
if (edges.length === 0) return out
|
||||
|
||||
const E = [...edges].sort((a, b) => a.tunnel.id.localeCompare(b.tunnel.id))
|
||||
const pairProbes = probesForServerPair(allProbes, E[0].fromServer, E[0].toServer)
|
||||
|
||||
if (E.length === 1) {
|
||||
out.set(
|
||||
E[0].tunnel.id,
|
||||
findSpeedProbeForGreEdge(E[0].tunnel, E[0].fromServer, E[0].toServer, allProbes, resolved),
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
const n = E.length
|
||||
const m = pairProbes.length
|
||||
|
||||
if (m === 0) {
|
||||
for (const e of E) out.set(e.tunnel.id, undefined)
|
||||
return out
|
||||
}
|
||||
|
||||
// Полное назначение: каждому туннелю своя проба, максимум суммы score (RT и MTS не делят одну пробу).
|
||||
let bestSum = -Infinity
|
||||
let bestAssign: Map<string, GreSpeedProbeSnapshot> | null = null
|
||||
|
||||
function dfs(i: number, used: Set<string>, cur: Map<string, GreSpeedProbeSnapshot>) {
|
||||
if (i === n) {
|
||||
let sum = 0
|
||||
for (const e of E) {
|
||||
const p = cur.get(e.tunnel.id)
|
||||
if (!p) return
|
||||
sum += scoreProbeForGreTunnel(p, e.tunnel, e.fromServer, e.toServer, resolved)
|
||||
}
|
||||
if (sum > bestSum) {
|
||||
bestSum = sum
|
||||
bestAssign = new Map(cur)
|
||||
}
|
||||
return
|
||||
}
|
||||
const edge = E[i]!
|
||||
for (const p of pairProbes) {
|
||||
if (used.has(p.id)) continue
|
||||
if (scoreProbeForGreTunnel(p, edge.tunnel, edge.fromServer, edge.toServer, resolved) < 0)
|
||||
continue
|
||||
used.add(p.id)
|
||||
cur.set(edge.tunnel.id, p)
|
||||
dfs(i + 1, used, cur)
|
||||
cur.delete(edge.tunnel.id)
|
||||
used.delete(p.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Перебор инъекций только при небольшом n — иначе сразу жадный алгоритм.
|
||||
if (n <= 7 && n <= m) dfs(0, new Set(), new Map())
|
||||
|
||||
/** TS не видит присваивание из замыкания dfs — явное приведение. */
|
||||
const inject = bestAssign as Map<string, GreSpeedProbeSnapshot> | null
|
||||
if (inject !== null && inject.size === n) {
|
||||
for (const ed of E) out.set(ed.tunnel.id, inject.get(ed.tunnel.id))
|
||||
return out
|
||||
}
|
||||
|
||||
// Недостаточно проб или нет полного матчинга — жадно по убыванию score, без повторов.
|
||||
const used = new Set<string>()
|
||||
const remaining = new Set(E.map((e) => e.tunnel.id))
|
||||
while (remaining.size > 0) {
|
||||
let pickEdge: GreTunnelProbeEdge | undefined
|
||||
let pickProbe: GreSpeedProbeSnapshot | undefined
|
||||
let pickScore = -Infinity
|
||||
|
||||
for (const tid of remaining) {
|
||||
const e = E.find((x) => x.tunnel.id === tid)!
|
||||
for (const p of pairProbes) {
|
||||
if (used.has(p.id)) continue
|
||||
const sc = scoreProbeForGreTunnel(p, e.tunnel, e.fromServer, e.toServer, resolved)
|
||||
if (sc < 0) continue
|
||||
if (
|
||||
sc > pickScore ||
|
||||
(sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0)
|
||||
) {
|
||||
pickScore = sc
|
||||
pickEdge = e
|
||||
pickProbe = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pickEdge || !pickProbe) {
|
||||
for (const tid of remaining) out.set(tid, undefined)
|
||||
break
|
||||
}
|
||||
|
||||
out.set(pickEdge.tunnel.id, pickProbe)
|
||||
used.add(pickProbe.id)
|
||||
remaining.delete(pickEdge.tunnel.id)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Подбирает speed-пробу для одного GRE (без учёта соседних туннелей той же пары узлов).
|
||||
* Для нескольких туннелей между теми же серверами используйте assignSpeedProbesToGreTunnels.
|
||||
*/
|
||||
export function findSpeedProbeForGreEdge(
|
||||
tunnel: GreTunnel,
|
||||
fromServer: Server,
|
||||
toServer: Server,
|
||||
probes: GreSpeedProbeSnapshot[],
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): GreSpeedProbeSnapshot | undefined {
|
||||
const cand = probes.filter(
|
||||
(p) =>
|
||||
p.enabled !== false &&
|
||||
scoreProbeForGreTunnel(p, tunnel, fromServer, toServer, resolvedIpv4ByHost) >= 0,
|
||||
)
|
||||
if (cand.length === 0) return undefined
|
||||
|
||||
let best = cand[0]!
|
||||
let bestScore = scoreProbeForGreTunnel(best, tunnel, fromServer, toServer, resolvedIpv4ByHost)
|
||||
for (let i = 1; i < cand.length; i++) {
|
||||
const p = cand[i]!
|
||||
const sc = scoreProbeForGreTunnel(p, tunnel, fromServer, toServer, resolvedIpv4ByHost)
|
||||
if (sc > bestScore) {
|
||||
best = p
|
||||
bestScore = sc
|
||||
continue
|
||||
}
|
||||
if (sc === bestScore) {
|
||||
const hasData = (x: GreSpeedProbeSnapshot) =>
|
||||
x.lastPingRttMs != null || x.lastTxAvgMbps != null || x.lastRxAvgMbps != null
|
||||
if (hasData(p) && !hasData(best)) best = p
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function mergeGreMetricsWithSpeedProbe(
|
||||
sp: GreSpeedProbeSnapshot | undefined,
|
||||
fallback: { pingMs: number | null; dlMbps: number | null; ulMbps: number | null },
|
||||
): {
|
||||
pingMs: number | null
|
||||
dlMbps: number | null
|
||||
ulMbps: number | null
|
||||
hasSpeedMonitor: boolean
|
||||
} {
|
||||
const hasSpeedMonitor =
|
||||
sp != null &&
|
||||
(sp.lastPingRttMs != null || sp.lastTxAvgMbps != null || sp.lastRxAvgMbps != null)
|
||||
const pingMs = sp?.lastPingRttMs ?? fallback.pingMs
|
||||
const dlMbps =
|
||||
sp?.lastTxAvgMbps != null ? Math.round(sp.lastTxAvgMbps) : fallback.dlMbps
|
||||
const ulMbps =
|
||||
sp?.lastRxAvgMbps != null ? Math.round(sp.lastRxAvgMbps) : fallback.ulMbps
|
||||
return { pingMs, dlMbps, ulMbps, hasSpeedMonitor }
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Раскладка узлов карты сети и синтетические WAN→JH рёбра по данным серверов (как в оптимизаторе).
|
||||
*/
|
||||
|
||||
import type { GreTunnel } from "@/lib/data"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import {
|
||||
greOuterComparableIps,
|
||||
normalizeGreEndpointAddr,
|
||||
wanUplinkIpMatchesGreOuter,
|
||||
} from "@/lib/gre-endpoint-resolve"
|
||||
|
||||
/** Визуализация метрик на GRE (нет отдельного API замеров для карты). */
|
||||
export interface TunnelProbe {
|
||||
pingMs: number | null
|
||||
dlMbps: number | null
|
||||
ulMbps: number | null
|
||||
}
|
||||
|
||||
export function greTunnelProbe(t: GreTunnel): TunnelProbe {
|
||||
if (t.status === "down" || !t.enabled)
|
||||
return { pingMs: null, dlMbps: null, ulMbps: null }
|
||||
const h = t.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
|
||||
if (t.status === "degraded") {
|
||||
return {
|
||||
pingMs: 35 + (h % 80),
|
||||
dlMbps: 40 + (h % 120),
|
||||
ulMbps: 35 + (h % 100),
|
||||
}
|
||||
}
|
||||
return {
|
||||
pingMs: 6 + (h % 30),
|
||||
dlMbps: 180 + (h % 520),
|
||||
ulMbps: 120 + (h % 400),
|
||||
}
|
||||
}
|
||||
|
||||
const W = 1060
|
||||
const H = 580
|
||||
const MARGIN = 72
|
||||
|
||||
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
||||
export const NETWORK_MAP_PIPELINE_Y = 300
|
||||
|
||||
/**
|
||||
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
||||
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
||||
*/
|
||||
export const NETWORK_MAP_LAYOUT_REVISION = 3
|
||||
|
||||
export interface WanJhEdge {
|
||||
homeId: string
|
||||
wanIdx: number
|
||||
jhId: string
|
||||
/** Сумма `latency` домашнего узла и JH из каталога (как в /servers); null если нет обоих замеров. */
|
||||
pingMs: number | null
|
||||
dlMbps: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface NetworkMapSrvRes {
|
||||
cpu: number
|
||||
cpuHistory: number[]
|
||||
ramUsed: number
|
||||
ramTotal: number
|
||||
hddUsed: number
|
||||
hddTotal: number
|
||||
uptimeSeconds: number
|
||||
temp?: number
|
||||
/** CPU/RAM/uptime сняты с последнего снапшота опроса; HDD в API нет — по-прежнему условная шкала. */
|
||||
fromPoll?: boolean
|
||||
}
|
||||
|
||||
/** Uptime из поля resource «1w2d3h4m5s» (как на бэкенде). */
|
||||
export function parseRouterosUptimeToSeconds(raw: string | null | undefined): number {
|
||||
if (!raw) return 0
|
||||
let total = 0
|
||||
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
|
||||
const n = Number.parseInt(m[1], 10)
|
||||
if (!Number.isFinite(n)) continue
|
||||
switch (m[2]) {
|
||||
case "w":
|
||||
total += n * 604800
|
||||
break
|
||||
case "d":
|
||||
total += n * 86400
|
||||
break
|
||||
case "h":
|
||||
total += n * 3600
|
||||
break
|
||||
case "m":
|
||||
total += n * 60
|
||||
break
|
||||
case "s":
|
||||
total += n
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Равномерно по отрезку [xMin, xMax] — несколько узлов в одной колонке. */
|
||||
function spreadInSegment(count: number, index: number, xMin: number, xMax: number): number {
|
||||
if (count <= 0) return (xMin + xMax) / 2
|
||||
if (count === 1) return (xMin + xMax) / 2
|
||||
return xMin + (index / (count - 1)) * (xMax - xMin)
|
||||
}
|
||||
|
||||
/** Детерминированные координаты узлов и «спутников» WAN под home-router. */
|
||||
export function computeNetworkMapLayout(servers: Server[]): {
|
||||
nodePos: Record<string, { x: number; y: number }>
|
||||
wanSatPos: Record<string, { x: number; y: number }[]>
|
||||
} {
|
||||
const nodePos: Record<string, { x: number; y: number }> = {}
|
||||
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
|
||||
|
||||
const span = W - 2 * MARGIN
|
||||
const laneGap = Math.min(44, span * 0.04)
|
||||
const laneW = (span - 2 * laneGap) / 3
|
||||
|
||||
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
|
||||
let xLane = MARGIN
|
||||
for (const type of laneOrder) {
|
||||
const group = servers.filter((s) => s.type === type)
|
||||
const xMin = xLane
|
||||
const xMax = xLane + laneW
|
||||
group.forEach((s, i) => {
|
||||
nodePos[s.id] = {
|
||||
x: spreadInSegment(group.length, i, xMin, xMax),
|
||||
y: NETWORK_MAP_PIPELINE_Y,
|
||||
}
|
||||
})
|
||||
xLane += laneW + laneGap
|
||||
}
|
||||
|
||||
const hr = servers.filter((s) => s.type === "home-router")
|
||||
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
|
||||
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
|
||||
|
||||
for (const r of hr) {
|
||||
const wans = r.wanUplinks?.length ? r.wanUplinks : []
|
||||
const base = nodePos[r.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
|
||||
const n = wans.length
|
||||
const satX = (base.x + jhColumnCenterX) / 2
|
||||
const rowY = NETWORK_MAP_PIPELINE_Y
|
||||
const maxV = H - 2 * MARGIN - 100
|
||||
const vGap =
|
||||
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
|
||||
const positions: { x: number; y: number }[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const rawY = n === 1 ? rowY : rowY + (i - (n - 1) / 2) * vGap
|
||||
const y = Math.max(MARGIN + 48, Math.min(H - 72, rawY))
|
||||
positions.push({ x: satX, y })
|
||||
}
|
||||
wanSatPos[r.id] = positions
|
||||
}
|
||||
|
||||
return { nodePos, wanSatPos }
|
||||
}
|
||||
|
||||
/**
|
||||
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
|
||||
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
||||
*/
|
||||
function legPing(home: Server, jh: Server): number | null {
|
||||
const hOk = home.enabled && home.status !== "offline"
|
||||
const jOk = jh.enabled && jh.status !== "offline"
|
||||
if (!hOk || !jOk) return null
|
||||
if (home.latency == null || jh.latency == null) return null
|
||||
return Math.min(995, Math.round(home.latency + jh.latency))
|
||||
}
|
||||
|
||||
/** Рёбра WAN→JumpHost для отрисовки (`latency` с бекенда — те же объекты, что и в каталоге серверов). */
|
||||
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
|
||||
const homes = servers.filter((s) => s.type === "home-router" && s.enabled)
|
||||
const jhs = servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
|
||||
if (homes.length === 0 || jhs.length === 0) return []
|
||||
|
||||
const edges: WanJhEdge[] = []
|
||||
for (const home of homes) {
|
||||
const wans = home.wanUplinks?.length ? home.wanUplinks : []
|
||||
if (wans.length === 0) continue
|
||||
for (let wanIdx = 0; wanIdx < wans.length; wanIdx++) {
|
||||
const wan = wans[wanIdx]
|
||||
for (const jh of jhs) {
|
||||
edges.push({
|
||||
homeId: home.id,
|
||||
wanIdx,
|
||||
jhId: jh.id,
|
||||
pingMs: legPing(home, jh),
|
||||
dlMbps: Math.max(1, wan.maxDl),
|
||||
active: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = (e: WanJhEdge) => `${e.homeId}::${e.wanIdx}`
|
||||
const score = (p: number | null) => (p == null ? Number.POSITIVE_INFINITY : p)
|
||||
const best = new Map<string, WanJhEdge>()
|
||||
for (const e of edges) {
|
||||
const k = key(e)
|
||||
const prev = best.get(k)
|
||||
if (!prev || score(e.pingMs) < score(prev.pingMs)) best.set(k, e)
|
||||
}
|
||||
for (const e of edges) {
|
||||
const winner = best.get(key(e))
|
||||
e.active =
|
||||
winner != null &&
|
||||
winner.pingMs != null &&
|
||||
e.jhId === winner.jhId &&
|
||||
e.pingMs === winner.pingMs
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
/** Ресурсы для карточки узла на карте: при наличии снапшота опроса — как в разделе «Серверы», иначе демо по id. */
|
||||
export function buildServerResourceMap(servers: Server[]): Record<string, NetworkMapSrvRes> {
|
||||
return Object.fromEntries(
|
||||
servers.map((s) => {
|
||||
const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
|
||||
const ramTotalDemo = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024
|
||||
const hddTotalDemo = s.type === "home-router" ? 2048 : 16384
|
||||
const ramPctDemo = 12 + (h % 72)
|
||||
const hddPctDemo = 8 + (h % 75)
|
||||
|
||||
const hasPollRam =
|
||||
s.cpuLoad != null &&
|
||||
typeof s.totalMemory === "number" &&
|
||||
s.totalMemory > 0 &&
|
||||
typeof s.freeMemory === "number"
|
||||
|
||||
if (hasPollRam) {
|
||||
const cpu = Math.max(0, Math.min(100, Math.round(s.cpuLoad!)))
|
||||
const ramTotalMb = Math.max(1, Math.round(s.totalMemory! / (1024 * 1024)))
|
||||
const usedBytes = Math.max(0, s.totalMemory! - s.freeMemory!)
|
||||
const ramUsedMb = Math.min(ramTotalMb, Math.max(0, Math.round(usedBytes / (1024 * 1024))))
|
||||
let uptimeSeconds = s.uptime ? parseRouterosUptimeToSeconds(s.uptime) : 0
|
||||
if (uptimeSeconds <= 0 && s.uptime) {
|
||||
uptimeSeconds =
|
||||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
|
||||
}
|
||||
if (uptimeSeconds <= 0) {
|
||||
uptimeSeconds =
|
||||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
|
||||
}
|
||||
const hddTotal = hddTotalDemo
|
||||
const hddUsed = Math.round((hddTotal * hddPctDemo) / 100)
|
||||
return [
|
||||
s.id,
|
||||
{
|
||||
cpu,
|
||||
cpuHistory: Array.from({ length: 32 }, (_, i) =>
|
||||
Math.max(0, Math.min(100, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 6))),
|
||||
),
|
||||
ramUsed: ramUsedMb,
|
||||
ramTotal: ramTotalMb,
|
||||
hddUsed,
|
||||
hddTotal,
|
||||
uptimeSeconds,
|
||||
fromPoll: true,
|
||||
} satisfies NetworkMapSrvRes,
|
||||
]
|
||||
}
|
||||
|
||||
const cpu = 4 + (h % 68)
|
||||
const ramTotal = ramTotalDemo
|
||||
const hddTotal = hddTotalDemo
|
||||
return [
|
||||
s.id,
|
||||
{
|
||||
cpu,
|
||||
cpuHistory: Array.from({ length: 32 }, (_, i) =>
|
||||
Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15))),
|
||||
),
|
||||
ramUsed: Math.round((ramTotal * ramPctDemo) / 100),
|
||||
ramTotal,
|
||||
hddUsed: Math.round((hddTotal * hddPctDemo) / 100),
|
||||
hddTotal,
|
||||
uptimeSeconds:
|
||||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
|
||||
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
|
||||
fromPoll: false,
|
||||
} satisfies NetworkMapSrvRes,
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── GRE: сопоставление remote-address с узлами (WAN ≠ API host в БД) ─────────
|
||||
|
||||
/** Нормализация IP/endpoint из RouterOS (CIDR, zone id). */
|
||||
export function normalizeGreEndpoint(addr: string): string {
|
||||
return normalizeGreEndpointAddr(addr)
|
||||
}
|
||||
|
||||
function hostWithoutPort(host: string): string {
|
||||
const h = host.trim()
|
||||
if (h.startsWith("[")) {
|
||||
const end = h.indexOf("]")
|
||||
return end > 0 ? h.slice(1, end) : h
|
||||
}
|
||||
const lastColon = h.lastIndexOf(":")
|
||||
if (lastColon > 0 && /^\d{1,5}$/.test(h.slice(lastColon + 1)))
|
||||
return h.slice(0, lastColon)
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти сервер в каталоге по GRE remote-address: совпадение с management host или WAN IP.
|
||||
* Для FQDN в remote-address — передайте карту DNS из `/api/network/resolve-hosts`.
|
||||
*
|
||||
* Важно: при успешном DNS исходный FQDN всё равно участвует в поиске первым — иначе совпадение
|
||||
* только по резолвнутому IP может выбрать «чужой» узел с тем же WAN, а speed-пробы перестанут
|
||||
* сходиться с парой концов туннеля.
|
||||
*/
|
||||
export function findServerByGreRemote(
|
||||
servers: Server[],
|
||||
remoteAddress: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): Server | undefined {
|
||||
const fallback = normalizeGreEndpoint(remoteAddress)
|
||||
if (!fallback || fallback === "0.0.0.0") return undefined
|
||||
|
||||
const comparable = greOuterComparableIps(remoteAddress, resolvedIpv4ByHost)
|
||||
const tryOrder: string[] = []
|
||||
const seen = new Set<string>()
|
||||
function pushCandidate(raw: string) {
|
||||
const n = normalizeGreEndpoint(raw)
|
||||
if (!n || n === "0.0.0.0" || seen.has(n)) return
|
||||
seen.add(n)
|
||||
tryOrder.push(n)
|
||||
}
|
||||
|
||||
pushCandidate(fallback)
|
||||
for (const ip of comparable) pushCandidate(ip)
|
||||
|
||||
for (const c of tryOrder) {
|
||||
for (const s of servers) {
|
||||
if (normalizeGreEndpoint(hostWithoutPort(s.host)) === c) return s
|
||||
}
|
||||
}
|
||||
for (const c of tryOrder) {
|
||||
for (const s of servers) {
|
||||
for (const w of s.wanUplinks ?? []) {
|
||||
if (normalizeGreEndpoint(w.ip) === c) return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Индекс WAN-аплинка Home Router по внешнему IP (GRE outer / remote-address). */
|
||||
export function wanIndexMatchingOuterIp(
|
||||
server: Server,
|
||||
outerIp: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): number | null {
|
||||
if (server.type !== "home-router") return null
|
||||
if (!normalizeGreEndpoint(outerIp)) return null
|
||||
const wans = server.wanUplinks ?? []
|
||||
for (let i = 0; i < wans.length; i++) {
|
||||
if (wanUplinkIpMatchesGreOuter(wans[i].ip, outerIp, resolvedIpv4ByHost)) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Индекс WAN-спутника на стороне узла, где поднят GRE (совпадает с якорем «source» на карте).
|
||||
* Для сопоставления ребра WAN→JH с GRE-бейджем на том же аплинке.
|
||||
*/
|
||||
export function greSourceWanIndexOnMap(
|
||||
home: Server,
|
||||
tunnel: GreTunnel,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): number | null {
|
||||
if (home.type !== "home-router") return null
|
||||
const wans = home.wanUplinks ?? []
|
||||
if (wans.length === 0) return null
|
||||
const la = tunnel.localAddress?.trim() ?? ""
|
||||
let wanIdx: number | null = null
|
||||
if (la === "0.0.0.0" || la === "") wanIdx = 0
|
||||
else wanIdx = wanIndexMatchingOuterIp(home, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
if (wanIdx == null) wanIdx = 0
|
||||
return wanIdx
|
||||
}
|
||||
|
||||
function greEndpointAnchor(
|
||||
server: Server,
|
||||
tunnel: GreTunnel,
|
||||
role: "source" | "peer",
|
||||
nodePos: Record<string, { x: number; y: number }>,
|
||||
wanSatPos: Record<string, { x: number; y: number }[]>,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): { x: number; y: number } {
|
||||
const center = (): { x: number; y: number } =>
|
||||
nodePos[server.id] ?? { x: W / 2, y: H / 2 }
|
||||
|
||||
if (server.type !== "home-router") return center()
|
||||
|
||||
const wans = server.wanUplinks ?? []
|
||||
let wanIdx: number | null = null
|
||||
if (role === "source") {
|
||||
const la = tunnel.localAddress?.trim() ?? ""
|
||||
if (la === "0.0.0.0" || la === "") wanIdx = wans.length > 0 ? 0 : null
|
||||
else wanIdx = wanIndexMatchingOuterIp(server, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
if (wanIdx == null && wans.length > 0) wanIdx = 0
|
||||
} else {
|
||||
wanIdx = wanIndexMatchingOuterIp(server, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||||
}
|
||||
|
||||
if (wanIdx != null && wanSatPos[server.id]?.[wanIdx])
|
||||
return wanSatPos[server.id][wanIdx]!
|
||||
return center()
|
||||
}
|
||||
|
||||
/** GRE на карте только между узлами каталога; у HR конец туннеля — у соответствующего WAN-спутника. */
|
||||
export interface GreMapEdge {
|
||||
tunnel: GreTunnel
|
||||
from: { x: number; y: number }
|
||||
to: { x: number; y: number }
|
||||
fromServer: Server
|
||||
toServer: Server
|
||||
}
|
||||
|
||||
export function buildGreMapEdges(
|
||||
tunnels: GreTunnel[],
|
||||
servers: Server[],
|
||||
nodePos: Record<string, { x: number; y: number }>,
|
||||
wanSatPos: Record<string, { x: number; y: number }[]>,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): GreMapEdge[] {
|
||||
const out: GreMapEdge[] = []
|
||||
for (const t of tunnels) {
|
||||
const fromServer = servers.find((s) => s.id === t.serverId)
|
||||
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
|
||||
if (!fromServer || !toServer) continue
|
||||
if (fromServer.id === toServer.id) continue
|
||||
|
||||
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||
out.push({ tunnel: t, from, to, fromServer, toServer })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Данные для страницы «Оптимизатор маршрутов» и единый набор параметров 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user