"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 { 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, buildLiveOptimizerData, DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS, mapApiServersToTopology, readStoredRouteOptimizerSettings, ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY, } 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 { const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} const res = await fetch(backendUrl.replace(/\/$/, "") + path, { ...init, headers: { ...headers, ...(init?.headers ?? {}) }, }) if (!res.ok) { let message = res.statusText try { const err = await res.json() as { error?: string } message = err.error ?? message } catch { const text = await res.text().catch(() => "") if (text) message = text } throw new Error(message) } return res.json() as Promise } } // ─── 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 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 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: calcScore(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 = 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: 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 => { 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 [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 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([]) return } const rows = await apiFetch("/api/servers") 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 } setData(buildLiveOptimizerData(rows, rulesets, s)) } catch (e) { setError(e instanceof Error ? e.message : "Ошибка загрузки данных") setData(null) setLiveJumpHosts([]) setLiveExitNodes([]) } 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)) ) } const set = (k: K, v: OptimizerSettings[K]) => setSettings(prev => ({ ...prev, [k]: v })) 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 .

)} {/* ─── 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) => ( ))}
) }