"use client" import { useCallback, useEffect, useState, useMemo, useRef } from "react" import Link from "next/link" import { PageHeader } from "@/components/page-header" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" import { requestJson } from "@/shared/api/http-client" import { servers } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { type HomeRouter, type JumpHost, type ExitNode, type WanJhLeg, type JhExLeg, type FullRoute, type CommRec, type HomeEntry, type OptimizerData, type OptimizerSettings, type OptimizerApiServer, type RouteOptimizerSpeedProbe, buildLiveOptimizerData, DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS, mapApiServersToTopology, readStoredRouteOptimizerSettings, ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY, calcRouteScore, } from "@/lib/route-optimizer-data" import { RefreshCwIcon, AlertCircleIcon, ArrowRightIcon, SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon, ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon, MonitorIcon, ServerIcon, GitMergeIcon, ShieldIcon, LayersIcon, InfoIcon, } from "lucide-react" function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } // ─── Topology derived from lib/data servers (mock) ─────────────────────────── const HOME_ROUTERS: HomeRouter[] = servers .filter(s => s.type === "home-router" && s.enabled) .map(s => ({ id: s.id, label: s.name, site: s.site, country: s.country, model: s.model, ip: s.host, wans: (s.wanUplinks ?? []).map(w => ({ id: w.id, name: w.name, isp: w.isp, iface: w.iface, ip: w.ip, maxDl: w.maxDl, maxUl: w.maxUl, })), })) const JUMPHOSTS: JumpHost[] = servers .filter(s => s.type === "jump-host" && s.status !== "offline") .map(s => ({ id: s.id, label: s.name, site: s.site, country: s.country, ip: s.host })) const EXIT_NODES: ExitNode[] = servers .filter(s => s.type === "exit-node" && s.status !== "offline") .map(s => ({ id: s.id, label: s.name, site: s.site, country: s.country, ip: s.host })) const VRF_NAMES: string[] = ["main", ...Array.from( new Set(servers.flatMap(s => s.vrfNames ?? [])) ).sort()] // ─── Mock data generator ────────────────────────────────────────────────────── function jitter(base: number, range: number) { return Math.max(1, Math.round(base + (Math.random() - 0.5) * range * 2)) } function confidence(prob: number): "HIGH" | "MEDIUM" | "LOW" { return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW" } // Base latencies and bandwidths for each (WAN, JH) pair const HW_BASE: Record = { "w1a-jh1": { ping: 4, dl: 480, ul: 420, loss: 0 }, "w1a-jh2": { ping: 6, dl: 480, ul: 420, loss: 0 }, "w1b-jh1": { ping: 12, dl: 95, ul: 90, loss: 0 }, "w1b-jh2": { ping: 14, dl: 94, ul: 88, loss: 0.5 }, "w2a-jh1": { ping: 18, dl: 280, ul: 270, loss: 0 }, "w2a-jh2": { ping: 20, dl: 278, ul: 265, loss: 0 }, "w2b-jh1": { ping: 22, dl: 185, ul: 140, loss: 0 }, "w2b-jh2": { ping: 25, dl: 182, ul: 138, loss: 1 }, } // Base latencies for (JH, Exit) pairs const JE_BASE: Record = { "jh1-ex1": { ping: 22, dl: 880, ul: 820 }, "jh1-ex2": { ping: 38, dl: 740, ul: 700 }, "jh1-ex3": { ping: 85, dl: 580, ul: 540 }, "jh1-ex4": { ping: 120, dl: 320, ul: 290 }, "jh2-ex1": { ping: 28, dl: 820, ul: 760 }, "jh2-ex2": { ping: 42, dl: 700, ul: 660 }, "jh2-ex3": { ping: 90, dl: 540, ul: 500 }, "jh2-ex4": { ping: 130, dl: 300, ul: 270 }, } function buildMockData(settings: OptimizerSettings): OptimizerData { const pw = settings.pingWeight // Pre-compute (JH, Exit) legs — shared across all homes const jhExLegs: JhExLeg[] = [] for (const jh of JUMPHOSTS) { for (const ex of EXIT_NODES) { const base = JE_BASE[`${jh.id}-${ex.id}`] if (!base) continue jhExLegs.push({ jhId: jh.id, exitId: ex.id, pingMs: jitter(base.ping, 4), dlMbps: jitter(base.dl, 30), ulMbps: jitter(base.ul, 25), }) } } function getJhEx(jhId: string, exitId: string) { return jhExLegs.find(l => l.jhId === jhId && l.exitId === exitId)! } const homes: HomeEntry[] = HOME_ROUTERS.map((home) => { // (WAN × JH) legs const wanJhLegs: WanJhLeg[] = [] for (const wan of home.wans) { for (const jh of JUMPHOSTS) { const base = HW_BASE[`${wan.id}-${jh.id}`] if (!base) continue const ping = jitter(base.ping, 3) const dl = Math.min(wan.maxDl, jitter(base.dl, 20)) const ul = Math.min(wan.maxUl, jitter(base.ul, 15)) wanJhLegs.push({ wanId: wan.id, jhId: jh.id, pingMs: ping, dlMbps: dl, ulMbps: ul, loss: base.loss > 0 ? +(base.loss + (Math.random() - 0.5) * 0.5).toFixed(1) : 0, score: calcRouteScore(ping, dl, ul, pw), }) } } // Full routes: every (WAN × JH × Exit) combo const fullRoutes: FullRoute[] = [] for (const wan of home.wans) { for (const jh of JUMPHOSTS) { for (const ex of EXIT_NODES) { const hw = wanJhLegs.find(l => l.wanId === wan.id && l.jhId === jh.id) const je = getJhEx(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: confidence(score), probabilityOptimal: 0, // assigned after ranking }) } } } // Assign probabilityOptimal proportional to score rank 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 - i * 6 + jitter(0, 3)) * (i === 0 ? 1 : 0.85 ** i))) }) sorted.sort((a, b) => b.probabilityOptimal - a.probabilityOptimal) // BGP community recommendations (per home) const best = sorted[0] ?? null const commRecs: CommRec[] = [ { community: "65001:100", communityName: "youtube-bypass", current: { wan: "WAN1-RT", jh: "mt-msk-core-01", exit: "mt-spb-edge-01", gateway: "10.0.1.1", prob: jitter(41, 5) }, recommended: best ? { wan: best.wan.name, jh: best.jh.label, exit: best.exit.label, gateway: best.je.pingMs < 30 ? "10.0.1.1" : "10.0.2.1", prob: jitter(55, 5) } : null, shouldSwitch: false, pinnedBySettings: false, }, { community: "65001:200", communityName: "streaming-eu", current: { wan: "WAN1-RT", jh: "mt-msk-core-01", exit: "mt-fra-edge-01", gateway: "10.0.2.1", prob: jitter(70, 5) }, recommended: { wan: "WAN1-RT", jh: "mt-msk-core-01", exit: "mt-fra-edge-01", gateway: "10.0.2.1", prob: jitter(74, 4) }, shouldSwitch: false, pinnedBySettings: false, }, { community: "65001:300", communityName: "cdn-bypass", current: { wan: "WAN2-BL", jh: "mt-msk-core-01", exit: "mt-fra-edge-01", gateway: "10.0.2.1", prob: jitter(28, 5) }, recommended: best ? { wan: best.wan.name, jh: best.jh.label, exit: "mt-spb-edge-01", gateway: "10.0.1.1", prob: jitter(62, 5) } : null, shouldSwitch: true, pinnedBySettings: false, }, ].map(r => ({ ...r, shouldSwitch: r.shouldSwitch && ((r.recommended?.prob ?? 0) - (r.current?.prob ?? 0)) >= settings.switchThreshold, })) return { home, wanJhLegs, fullRoutes: sorted, bestRoute: sorted[0] ?? null, commRecs } }) return { updatedAt: new Date().toLocaleTimeString("ru-RU"), homes } } // ─── Small UI helpers ───────────────────────────────────────────────────────── function Chip({ children, color }: { children: React.ReactNode; color?: string }) { return ( {children} ) } function ProbChip({ prob, best }: { prob: number; best?: boolean }) { return ( {prob}% ) } function ConfChip({ conf }: { conf: string }) { const map: Record = { HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", } return {conf} } function LossChip({ loss }: { loss: number }) { if (loss === 0) return 0% return ( 1 ? "text-red-500" : "text-amber-500")}> {loss}% ) } function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ) } function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) { return ( onChange(Math.max(min ?? 0, Math.min(max ?? 9999, parseInt(e.target.value) || 0)))} className="w-16 h-7 text-xs font-mono text-right" /> ) } function SettingRow({ label, unit, children }: { label: string; unit?: string; children: React.ReactNode }) { return (
{label}
{children} {unit && {unit}}
) } // ─── WAN Matrix table ───────────────────────────────────────────────────────── // Rows = WANs, Columns = JHs, cells show ping / bw / score function WanMatrix({ home, legs, jumpHosts, pw: _pw }: { home: HomeRouter legs: WanJhLeg[] jumpHosts: JumpHost[] pw: number }) { // find best leg overall const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0 return (
{jumpHosts.map(jh => ( ))} {home.wans.map(wan => ( {/* WAN name */} {/* ISP */} {/* Max bandwidth */} {/* Per-JH cells */} {jumpHosts.map(jh => { const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id) if (!leg) return const isBest = leg.score === bestScore return ( ) })} ))}
WAN-аплинк ISP / IP Макс. полоса
{jh.label}
{jh.site} · {jh.ip}

{wan.name}

{wan.iface}

{wan.isp}

{wan.ip}

↓{wan.maxDl}

↑{wan.maxUl} Мбит

{isBest && ( ★ ЛУЧШИЙ )} {leg.pingMs} мс ↓{leg.dlMbps} ↑{leg.ulMbps}
score {leg.score} {leg.loss > 0 && }
) } // ─── Full routes table ──────────────────────────────────────────────────────── function FullRoutesTable({ routes, bestId }: { routes: FullRoute[]; bestId?: string }) { const [expanded, setExpanded] = useState(false) const visible = expanded ? routes : routes.slice(0, 5) return (
{visible.map((r, i) => { const isBest = r.id === bestId || i === 0 const totalPing = r.hw.pingMs + r.je.pingMs const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps) const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps) return ( ) })}
# Маршрут WAN → JH JH → Exit Ping (итого) BW (мин) Score P(opt) Conf.
{i + 1} {isBest && ( Лучший )}
{r.wan.name}
{r.jh.label}
{r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}
{r.exit.label} ({r.exit.site})
{r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}
{totalPing} мс
↓{minDl}
↑{minUl}
{r.score}
{routes.length > 5 && ( )}
) } // ─── Community recs table ───────────────────────────────────────────────────── function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply, threshold: _threshold }: { recs: CommRec[] homeId: string pinned: Set applied: Set applying: Set onPin: (k: string) => void onApply: (comm: string, homeId: string) => void threshold: number }) { return (
{recs.map((r, idx) => { const pinKey = `${homeId}::${r.community}` const isPinned = pinned.has(pinKey) const isApplied = applied.has(pinKey) const isApplying = applying.has(pinKey) const canApply = r.shouldSwitch && !isPinned && !isApplied return ( {/* community */} {/* current route */} {/* recommended */} {/* probability */} {/* actions */} ) })}
Community Текущий (WAN → JH → Exit) Рекомендуемый P(тек / рек) Действие
{r.community}
{r.communityName}
{r.current ? (
{r.current.wan} {r.current.jh} {r.current.exit} ({r.current.gateway})
) : }
{r.recommended ? (
{r.recommended.wan} {r.recommended.jh} {r.recommended.exit} {r.shouldSwitch && !isPinned && ( +{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}% )}
) : }
/
{isPinned && } {canApply && ( )} {isApplied && ( Применено )}
) } // ─── Home Router card ───────────────────────────────────────────────────────── type HomeTab = "wan-matrix" | "full-routes" | "bgp-community" function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying, onPin, onApply }: { entry: HomeEntry jumpHosts: JumpHost[] settings: OptimizerSettings pinned: Set applied: Set applying: Set onPin: (k: string) => void onApply: (comm: string, homeId: string) => void }) { const [tab, setTab] = useState("wan-matrix") const { home, wanJhLegs, fullRoutes, bestRoute, commRecs } = entry const switchCount = commRecs.filter(r => r.shouldSwitch && !pinned.has(`${home.id}::${r.community}`)).length return ( {/* Header */}
{home.label} {home.site} {home.model}
{home.wans.map(w => `${w.name} (${w.isp} · ${w.ip})`).join(" · ")}
{/* Best route summary */} {bestRoute && (
{bestRoute.wan.name} {bestRoute.jh.label} {bestRoute.exit.label}
)}
{switchCount > 0 && ( {switchCount} переключений )} {bestRoute && ( <> )}
{/* Sub-tabs */}
{([ { id: "wan-matrix", label: `WAN × JH (${home.wans.length}×${jumpHosts.length})` }, { id: "full-routes", label: `Маршруты (${fullRoutes.length})` }, { id: "bgp-community", label: `BGP community (${commRecs.length})` }, ] as { id: HomeTab; label: string }[]).map(t => ( ))}
{/* Tab content */} {tab === "wan-matrix" && ( )} {tab === "full-routes" && ( )} {tab === "bgp-community" && ( )}
) } // ─── Topology legend ────────────────────────────────────────────────────────── function TopologyBar() { return (
Home Router
WAN1 / WAN2
JumpHost
Exit Node
) } // ════════════════════════════════════════════════════════════════════════════ export default function RouteOptimizerPage() { const { mode, backendUrl, backendStatus } = useDataSource() /** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */ const useLiveData = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [prefsLoaded, setPrefsLoaded] = useState(false) const [loading, setLoading] = useState(true) const [data, setData] = useState(null) const [error, setError] = useState("") const [settings, setSettings] = useState(DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS) const settingsRef = useRef(settings) useEffect(() => { settingsRef.current = settings }, [settings]) useEffect(() => { queueMicrotask(() => { setSettings(readStoredRouteOptimizerSettings()) setPrefsLoaded(true) }) }, []) useEffect(() => { if (!prefsLoaded) return try { localStorage.setItem(ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY, JSON.stringify(settings)) } catch { /* ignore quota */ } }, [settings, prefsLoaded]) const [liveJumpHosts, setLiveJumpHosts] = useState([]) const [liveExitNodes, setLiveExitNodes] = useState([]) const [liveServers, setLiveServers] = useState([]) const [showSettings, setShowSettings] = useState(false) const [pinned, setPinned] = useState>(new Set()) const [applied, setApplied] = useState>(new Set()) const [applying, setApplying] = useState>(new Set()) const [ecmpEnabled, setEcmpEnabled] = useState(false) const [ecmpMaxPaths, setEcmpMaxPaths] = useState(4) const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst") const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled") const [selectedVrf, setSelectedVrf] = useState("main") const [ospfServerId, setOspfServerId] = useState("") const [ospfApplying, setOspfApplying] = useState(false) const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null) const [ospfApplyError, setOspfApplyError] = useState("") const [ospfMeta, setOspfMeta] = useState<{ interfaces: number; areas: number; serverName: string } | null>(null) const [ospfPreviewError, setOspfPreviewError] = useState("") const [ospfPreview, setOspfPreview] = useState<{ changedCount: number interfacesTotal: number interfaces: Array<{ interface: string currentCost: number optimalCost: number score: number pingMs: number dlMbps: number ulMbps: number }> changes: Array<{ interface: string currentCost: number optimalCost: number score: number pingMs: number dlMbps: number ulMbps: number }> } | null>(null) const load = useCallback(async (override?: OptimizerSettings) => { const s = override ?? settingsRef.current setLoading(true) setError("") try { if (!useLiveData) { await new Promise((r) => setTimeout(r, 450)) setData(buildMockData(s)) setLiveJumpHosts([]) setLiveExitNodes([]) setLiveServers([]) return } const rows = await apiFetch("/api/servers") const enabledRows = rows.filter((r) => r.enabled) setLiveServers(enabledRows) setOspfServerId((prev) => (prev && enabledRows.some((r) => String(r.id) === prev) ? prev : String(enabledRows[0]?.id ?? ""))) const { jumpHosts, exitNodes } = mapApiServersToTopology(rows) setLiveJumpHosts(jumpHosts) setLiveExitNodes(exitNodes) let rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> | null = null try { const fr = await apiFetch<{ rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> }>( "/api/filters/rules", ) rulesets = fr.rulesets ?? null } catch { rulesets = null } let speedProbes: RouteOptimizerSpeedProbe[] = [] try { const sp = await apiFetch<{ probes: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes") speedProbes = sp.probes ?? [] } catch { speedProbes = [] } setData(buildLiveOptimizerData(rows, rulesets, s, speedProbes)) } catch (e) { setError(e instanceof Error ? e.message : "Ошибка загрузки данных") setData(null) setLiveJumpHosts([]) setLiveExitNodes([]) setLiveServers([]) } finally { setLoading(false) } }, [apiFetch, useLiveData]) useEffect(() => { if (!prefsLoaded) return queueMicrotask(() => { void load() }) }, [load, useLiveData, prefsLoaded]) const pollMs = useMemo(() => { if (!useLiveData) return 30_000 const m = Math.min(Math.max(settings.probeIntervalMin, 1), 30) return m * 60_000 }, [useLiveData, settings.probeIntervalMin]) useEffect(() => { if (!prefsLoaded) return const id = setInterval(() => { void load() }, pollMs) return () => clearInterval(id) }, [load, pollMs, prefsLoaded]) function togglePin(key: string) { setPinned(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n }) } function applyRec(community: string, homeId: string) { const key = `${homeId}::${community}` setApplying(prev => new Set(prev).add(key)) setTimeout(() => { setApplied(prev => new Set(prev).add(key)) setApplying(prev => { const n = new Set(prev); n.delete(key); return n }) }, 1200) } const totalSwitches = useMemo( () => data?.homes.flatMap((h) => h.commRecs.filter( (r) => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`), ), ).length ?? 0, [data, pinned], ) const jumpHostsForCards = useLiveData ? liveJumpHosts : JUMPHOSTS const statsChips = useMemo(() => { const homeCount = useLiveData ? (data?.homes.length ?? 0) : HOME_ROUTERS.length const wanCount = useLiveData ? (data?.homes.reduce((s, h) => s + h.home.wans.length, 0) ?? 0) : HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0) const jh = useLiveData ? liveJumpHosts : JUMPHOSTS const ex = useLiveData ? liveExitNodes : EXIT_NODES const jhSub = jh.length ? jh.map((j) => j.site).join(" · ") : "—" const exSub = ex.length ? ex.map((e) => e.site).join(" · ") : "—" return [ { label: "Home роутеров", value: homeCount, sub: `${wanCount} WAN-аплинков`, icon: , }, { label: "JumpHost", value: jh.length, sub: jhSub, icon: , }, { label: "Exit Node", value: ex.length, sub: exSub, icon: , }, { label: "Переключений", value: totalSwitches, sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально", icon: ( 0 ? "text-amber-500" : "text-muted-foreground", )} /> ), }, ] }, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches]) function applyAll() { data?.homes.forEach(h => h.commRecs .filter(r => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`)) .forEach(r => applyRec(r.community, h.home.id)) ) } async function applyOspfOptimization() { if (!ospfServerId) return setOspfApplying(true) setOspfApplyError("") setOspfApplyResult(null) try { const res = await apiFetch<{ serverName: string; optimizedCount: number }>( `/api/servers/${ospfServerId}/ospf/optimize`, { method: "POST", body: JSON.stringify({ pingWeight: settings.pingWeight }), }, ) setOspfApplyResult({ serverName: res.serverName, optimizedCount: res.optimizedCount, }) await load() setOspfPreview((prev) => (prev ? { ...prev, changedCount: 0, changes: [] } : prev)) } catch (e) { setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации") } finally { setOspfApplying(false) } } const set = (k: K, v: OptimizerSettings[K]) => setSettings(prev => ({ ...prev, [k]: v })) useEffect(() => { if (!useLiveData || !ospfServerId) return let cancelled = false void apiFetch<{ interfaces: Array<{ areaId: string; interface: string }> instances: Array neighbors: Array bfdSessions: Array }>(`/api/servers/${ospfServerId}/ospf`) .then((data) => { if (cancelled) return const selected = liveServers.find((s) => String(s.id) === ospfServerId) const visibleInterfaces = data.interfaces.filter((i) => !/^\(ref\s+\*.+\)$/.test(i.interface.trim())) setOspfMeta({ interfaces: visibleInterfaces.length, areas: new Set(visibleInterfaces.map((i) => i.areaId)).size, serverName: selected?.name || selected?.host || ospfServerId, }) }) .catch(() => { if (cancelled) return setOspfMeta(null) }) return () => { cancelled = true } }, [apiFetch, liveServers, ospfServerId, useLiveData]) useEffect(() => { if (!useLiveData || !ospfServerId) return let cancelled = false void apiFetch<{ changedCount: number interfacesTotal: number interfaces: Array<{ interface: string currentCost: number optimalCost: number score: number pingMs: number dlMbps: number ulMbps: number }> changes: Array<{ interface: string currentCost: number optimalCost: number score: number pingMs: number dlMbps: number ulMbps: number }> }>( `/api/servers/${ospfServerId}/ospf/optimize/preview`, { method: "POST", body: JSON.stringify({ pingWeight: settings.pingWeight }) }, ) .then((data) => { if (cancelled) return setOspfPreviewError("") setOspfPreview(data) }) .catch((e) => { if (cancelled) return setOspfPreview(null) setOspfPreviewError(e instanceof Error ? e.message : "Ошибка preview OSPF") }) return () => { cancelled = true } }, [apiFetch, ospfServerId, settings.pingWeight, useLiveData]) const ospfPreviewLoading = useLiveData && Boolean(ospfServerId) && !ospfPreview && !ospfPreviewError return (
{totalSwitches > 0 && ( )} } />
{/* Status row */}
· Обновлено: {data?.updatedAt ?? "—"} · Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"} {useLiveData ? "Живые данные · API" : "Демо · mock"} {useLiveData && backendStatus === false && ( бекенд не отвечает на /health — проверьте URL в настройках )}
{/* Stats chips */}
{statsChips.map((s) => (

{s.label}

{s.value}

{s.sub}

{s.icon}
))}
{error && (
{error}
)} {loading && !data && (
Расчёт оптимальных маршрутов…
)} {/* Settings */}
setShowSettings(v => !v)} > Настройки оптимизатора порог {settings.switchThreshold}% · ping {settings.pingWeight}% · зондирование {settings.probeIntervalMin} мин {showSettings ? : }
{showSettings && (

Пороги переключения

set("switchThreshold", v)} min={0} max={50} /> set("hysteresisThreshold", v)} min={0} max={50} />

Веса метрик

set("pingWeight", Math.min(100, v))} min={0} max={100} />
Ping {settings.pingWeight}% BW {100 - settings.pingWeight}%
{[5, 10, 15, 30].map(v => ( ))}

Автоприменение

Применять автоматически set("autoApply", v)} />
{settings.autoApply && ( <> set("autoApplyIntervalMin", v)} min={5} max={1440} />
Изменения каждые {settings.autoApplyIntervalMin} мин
)}

Базовые значения совпадают с разделом{" "} Настройки → Route AI .

)} {/* OSPF optimization from Route Optimizer */}
OSPF Route AI weight: ping {settings.pingWeight}%
{!useLiveData && (

Доступно только в режиме живых данных.

)} {useLiveData && ( <>
{ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
Router
{ospfMeta?.serverName ?? "—"}
apply: {ospfApplyResult?.optimizedCount ?? 0}
Preview изменений OSPF cost (до применения) {ospfPreviewLoading ? "расчёт…" : ospfPreview ? `${ospfPreview.changedCount} из ${ospfPreview.interfacesTotal} изменятся` : "нет данных"}
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => ( ))} {ospfPreviewError && ( )} {!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && ( )} {(ospfPreview?.interfaces ?? []).map((row) => ( ))}
{h}
Ошибка preview: {ospfPreviewError}
Интерфейсы OSPF не найдены для выбранного сервера.
{row.interface} {row.currentCost} {" → "} {row.optimalCost} {row.score} {row.pingMs}ms ↓{row.dlMbps} / ↑{row.ulMbps}
{ospfApplyResult && (
Применено на {ospfApplyResult.serverName}: изменено интерфейсов — {ospfApplyResult.optimizedCount}.
)} {ospfApplyError && (
Ошибка OSPF-оптимизации: {ospfApplyError}
)} )}
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
{/* ECMP Card */}
ECMP Equal-Cost Multi-Path
Макс. путей
setEcmpMaxPaths(Number(e.target.value))} className="flex-1 accent-violet-500 h-1.5" /> {ecmpMaxPaths}
Алгоритм балансировки
{([["per-dst","По dst"],["per-conn","По conn"],["per-packet","По пакет"]] as const).map(([v, l]) => ( ))}
RouterOS 7.x: /routing/rule add ecmp=yes
{/* RPF Card */}
RPF Reverse Path Forwarding
Режим проверки источника
{([ { v: "disabled", l: "Отключён", d: "Проверка не выполняется", cls: "text-muted-foreground" }, { v: "loose", l: "Loose", d: "Маршрут к источнику существует", cls: "text-amber-500" }, { v: "strict", l: "Strict", d: "Пакет пришёл по лучшему маршруту", cls: "text-emerald-500" }, ] as const).map(({ v, l, d, cls }) => ( ))}
{rpfMode !== "disabled" && (
/ip settings set rp-filter={rpfMode}
)}
{/* VRF Card */}
VRF Virtual Routing
Контекст оптимизации маршрутов
{VRF_NAMES.map(vrf => { const isSel = selectedVrf === vrf const isMain = vrf === "main" return ( ) })}
Оптимизатор работает в VRF {selectedVrf}
{/* Per-home-router cards */} {data?.homes.map((entry) => ( ))}
) }