Files
MikrotikManager/app/(main)/route-optimizer/page.tsx
T

1079 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useEffect, useState, useMemo, useRef, Suspense } from "react"
import Link from "next/link"
import {useSearchParams} from "next/navigation"
import {can} from "@/lib/auth"
import {ospfCatalogSchema,ospfOptimizeSchema} from "@/lib/ospf-response"
import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema,optimizerOspfPreviewSchema,type OptimizerOspfPreview} from "@/lib/route-optimizer-response"
import { OptimizerHomesList } from "@/components/optimizer-homes-list"
import { OptimizerSiteSummary } from "@/components/optimizer-site-summary"
import { PageHeader } from "@/components/page-header"
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
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,
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,
ZapIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
MonitorIcon, ServerIcon,
InfoIcon,
} from "lucide-react"
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(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<string, { ping: number; dl: number; ul: number; loss: number }> = {
"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<string, { ping: number; dl: number; ul: number }> = {
"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${JUMPHOSTS.indexOf(jh) + 1}-ex${EXIT_NODES.indexOf(ex) + 1}`]
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${JUMPHOSTS.indexOf(jh) + 1}`]
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 (
<span className={cn(
"inline-flex shrink-0 whitespace-nowrap items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
color ?? "bg-muted text-muted-foreground border-border",
)}>{children}</span>
)
}
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
return (
<Chip color={best
? "bg-success/10 text-success border-success/20"
: "bg-muted text-muted-foreground border-border"
}><span className="sr-only">Индекс модели: </span>{prob} б.</Chip>
)
}
function ConfChip({ conf }: { conf: string }) {
const map: Record<string, string> = {
HIGH: "bg-info/10 text-info border-info/20",
MEDIUM: "bg-warning/10 text-warning border-warning/20",
LOW: "bg-destructive/10 text-destructive border-destructive/20",
}
return <Chip color={map[conf] ?? map.LOW}><span className="sr-only">Категория модели: </span>{conf}</Chip>
}
function NInput({ value, onChange, min, max, label, disabled }: { disabled?: boolean; label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
return (
<Input disabled={disabled} aria-label={label} type="number" value={value} min={min} max={max}
onChange={e => 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 (
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5 shrink-0">
{children}
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
</div>
</div>
)
}
// ─── Home Router card ─────────────────────────────────────────────────────────
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
function HomeRouterCard({ entry, jumpHosts, pinned, applied, applying, onPin, onApply, simulation }: {
entry: HomeEntry
jumpHosts: JumpHost[]
pinned: Set<string>
applied: Set<string>
applying: Set<string>
onPin: (k: string) => void
onApply: (comm: string, homeId: string) => void
simulation: boolean
}) {
const [tab, setTab] = useState<HomeTab>("wan-matrix")
const { home, wanJhLegs, fullRoutes, bestRoute, commRecs } = entry
const switchCount = commRecs.filter(r => r.shouldSwitch && !pinned.has(`${home.id}::${r.community}`)).length
return (
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
<MonitorIcon aria-hidden="true" className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<Flag code={home.country} />
<span className="text-sm font-semibold font-mono break-all">{home.label}</span>
<span className="text-xs text-muted-foreground break-all">{home.site}</span>
<span className="text-[10px] text-muted-foreground border border-border rounded px-1.5 py-0.5 font-mono break-all">{home.model}</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono mt-0.5 break-all">
{home.wans.slice(0,3).map(w => `${w.name} (${w.isp} · ${w.ip})`).join(" · ")}
{home.wans.length>3&&<p className="font-sans mt-1">Ещё {home.wans.length-3} WAN. Все {home.wans.length} аплинков — во вкладке WAN × JH.</p>}
</div>
</div>
{/* Best route summary */}
{bestRoute && (
<div className="flex items-center gap-1.5 ml-2 text-xs text-muted-foreground flex-wrap min-w-0 break-all">
<span className="font-mono font-semibold text-info ">{bestRoute.wan.name}</span>
<ArrowRightIcon aria-hidden="true" className="size-3" />
<span>{bestRoute.jh.label}</span>
<ArrowRightIcon aria-hidden="true" className="size-3" />
<span className="flex items-center gap-1"><Flag code={bestRoute.exit.country} />{bestRoute.exit.label}</span>
</div>
)}
<div className="ml-auto flex items-center gap-2 flex-wrap min-w-0 max-w-full">
{switchCount > 0 && (
<Chip color="bg-warning/10 text-warning border-warning/20">
Рекомендаций: {switchCount}
</Chip>
)}
{bestRoute && (
<>
<ConfChip conf={bestRoute.confidence} />
<ProbChip prob={bestRoute.probabilityOptimal} best />
</>
)}
</div>
</div>
{/* Sub-tabs */}
<div className="flex items-center gap-0 overflow-x-auto border-b bg-muted/20 px-1">
{([
{ 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 => (
<button type="button" aria-pressed={tab === t.id} key={t.id} onClick={() => setTab(t.id)}
className={cn(
"shrink-0 whitespace-nowrap px-4 py-2 text-xs font-medium border-b-2 transition-colors -mb-px focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
tab === t.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}>
{t.label}
</button>
))}
</div>
{home.wans.length === 0 && <p className="px-5 py-3 text-xs text-muted-foreground">Нет WAN-аплинков для расчёта. <Link className="underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-ring" href={"/servers?server="+encodeURIComponent(home.id)}>Настроить сервер</Link></p>}
{/* Tab content */}
{tab === "wan-matrix" && (
<RouteOptimizerWanMatrixDataGrid home={home} legs={wanJhLegs} jumpHosts={jumpHosts} />
)}
{tab === "full-routes" && (
<RouteOptimizerFullRoutesDataGrid routes={fullRoutes} bestId={bestRoute?.id} />
)}
{tab === "bgp-community" && (
<RouteOptimizerCommRecsDataGrid
recs={commRecs}
homeId={home.id}
pinned={pinned} applied={applied} applying={applying}
onPin={onPin} onApply={onApply} simulation={simulation}
/>
)}
</FramePanel>
</Frame>
)
}
// ─── Topology legend ──────────────────────────────────────────────────────────
function TopologyBar() {
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<div className="flex items-center gap-1.5">
<MonitorIcon aria-hidden="true" className="size-3.5" />
<span>Схема: Home Router</span>
</div>
<span className="text-border">→</span>
<div className="flex items-center gap-1.5">
<WifiIcon aria-hidden="true" className="size-3.5 text-info" />
<span>WAN-аплинк</span>
</div>
<span className="text-border">→</span>
<div className="flex items-center gap-1.5">
<ServerIcon aria-hidden="true" className="size-3.5 text-focus" />
<span>JumpHost</span>
</div>
<span className="text-border">→</span>
<div className="flex items-center gap-1.5">
<NetworkIcon aria-hidden="true" className="size-3.5 text-success" />
<span>Exit Node</span>
</div>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function RouteOptimizerPage() {
return <Suspense fallback={<p role="status">Загрузка контекста оптимизатора…</p>}><RouteOptimizerRoute /></Suspense>
}
function RouteOptimizerRoute() {
const {mode,backendUrl}=useDataSource()
const params=useSearchParams()
const requestedServer=params.get("server")
function selectServer(server:string) {
const url=new URL(window.location.href)
url.searchParams.set("server",server)
window.history.pushState(null,"",url.pathname+url.search+url.hash)
}
return <RouteOptimizerContent key={JSON.stringify([mode,backendUrl])} requestedServer={requestedServer} onSelectServer={selectServer} />
}
function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer:string|null;onSelectServer:(server:string)=>void}) {
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<OptimizerData | null>(null)
const [error, setError] = useState("")
const [readAt, setReadAt] = useState<string | null>(null)
const reader = useRef({ alive: true, epoch: 0 })
useEffect(() => { const state = reader.current; state.alive = true; return () => { state.alive = false; state.epoch++ } }, [])
const [settings, setSettings] = useState<OptimizerSettings>(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<JumpHost[]>([])
const [liveExitNodes, setLiveExitNodes] = useState<ExitNode[]>([])
const [liveServers, setLiveServers] = useState<OptimizerApiServer[]>([])
const [showSettings, setShowSettings] = useState(false)
const [pinned, setPinned] = useState<Set<string>>(new Set())
const [applied, setApplied] = useState<Set<string>>(new Set())
const [applying, setApplying] = useState<Set<string>>(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=requestedServer ?? String(liveServers[0]?.id ?? "")
const ospfEligible=/^[1-9]\d*$/.test(ospfServerId) && liveServers.some(server=>String(server.id)===ospfServerId)
const contextError=requestedServer!==null && !loading && !ospfEligible ? "Устройство из ссылки неизвестно или отключено. Выберите доступное устройство." : ""
const [ospfApplying, setOspfApplying] = useState(false)
const [applyResult, setApplyResult] = useState<{owner:string;serverName:string;optimizedCount:number;receivedAt:string}|null>(null)
const [applyError,setApplyError]=useState({owner:"",message:""})
const writeLock=useRef(false),activeContext=useRef("")
const [metaState,setMetaState]=useState<{owner:string;data:{interfaces:number;areas:number;serverName:string}|null;error:string}>({owner:"",data:null,error:""})
const [previewState,setPreviewState]=useState<{owner:string;data:OptimizerOspfPreview|null;error:string;pending:boolean}>({owner:"",data:null,error:"",pending:false})
const [previewTick,setPreviewTick]=useState(0)
const previewKey=JSON.stringify([ospfServerId,settings.pingWeight])
const ospfMeta=metaState.owner===ospfServerId?metaState.data:null
const ospfMetaError=metaState.owner===ospfServerId?metaState.error:""
const ospfPreview=previewState.owner===previewKey?previewState.data:null
const ospfPreviewError=previewState.owner===previewKey?previewState.error:""
const ospfPreviewLoading=useLiveData && ospfEligible && (previewState.owner!==previewKey || previewState.pending)
useEffect(()=>{activeContext.current=previewKey},[previewKey])
const ospfApplyResult=applyResult?.owner===previewKey?applyResult:null
const ospfApplyError=applyError.owner===previewKey?applyError.message:""
const previewReady=Boolean(ospfPreview) && !ospfPreviewLoading && !ospfPreviewError
const load = useCallback(async (override?: OptimizerSettings) => {
const epoch = ++reader.current.epoch
const current = () => reader.current.alive && reader.current.epoch === epoch
const settings = override ?? settingsRef.current
if (!current()) return
setLoading(true)
try {
if (!useLiveData) {
await new Promise(resolve => setTimeout(resolve, 450))
if (!current()) return
setData(buildMockData(settings)); setError(""); setReadAt(null)
return
}
async function readPart<T>(path:string, parse:(raw:unknown)=>T, label:string) {
let raw:unknown
try { raw = await apiFetch<unknown>(path) }
catch (error) { throw new Error(label + ": " + (error instanceof Error ? error.message : "ошибка API")) }
try { return parse(raw) } catch { throw new Error(label + ": недостоверный ответ API") }
}
const [inventory,rules,probes] = await Promise.allSettled([
readPart("/api/servers",raw=>optimizerServersSchema.parse(raw),"Каталог устройств"),
readPart("/api/filters/rules",raw=>optimizerRulesSchema.parse(raw),"Правила Community"),
readPart("/api/uptime/speed-probes",raw=>optimizerProbesSchema.parse(raw),"Измерения speed-probe"),
])
if (!current()) return
const failures = [inventory,rules,probes].flatMap(result=>result.status==="rejected"?[result.reason instanceof Error?result.reason.message:"Ошибка данных"]:[])
if (inventory.status!=="fulfilled" || rules.status!=="fulfilled" || probes.status!=="fulfilled") throw new Error(failures.join("; "))
const rows = inventory.value
const ids = new Set(rows.map(row=>String(row.id)))
if (rules.value.rulesets.some(set=>!ids.has(set.serverId)) || probes.value.probes.some(probe=>!ids.has(probe.srcServerId)||!ids.has(probe.dstServerId))) throw new Error("Правила или измерения относятся к неизвестному устройству")
const next = buildLiveOptimizerData(rows, rules.value.rulesets, settings, probes.value.probes)
const {jumpHosts,exitNodes} = mapApiServersToTopology(rows)
const enabledRows=rows.filter(row=>row.enabled)
setLiveServers(enabledRows);setLiveJumpHosts(jumpHosts);setLiveExitNodes(exitNodes)
setData(next);setError("");setReadAt(new Date().toISOString())
} catch (error) {
if (current()) setError(error instanceof Error ? error.message : "Ошибка загрузки данных")
} finally {
if (current()) 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 })
}
const simulationTimers = useRef(new Set<ReturnType<typeof setTimeout>>())
const pendingSimulations = useRef(new Set<string>())
useEffect(() => () => { for (const timer of simulationTimers.current) clearTimeout(timer) }, [])
function applyRec(community: string, homeId: string) {
if (useLiveData) return
const key = `${homeId}::${community}`
if (pendingSimulations.current.has(key) || applied.has(key)) return
pendingSimulations.current.add(key)
setApplying(prev => new Set(prev).add(key))
const timer = setTimeout(() => {
pendingSimulations.current.delete(key)
simulationTimers.current.delete(timer)
setApplied(prev => new Set(prev).add(key))
setApplying(prev => { const n = new Set(prev); n.delete(key); return n })
}, 1200)
simulationTimers.current.add(timer)
}
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 = <OptimizerSiteSummary kind="JumpHost" sites={useLiveData && !data ? undefined : jh.map(j=>j.site)} />
const exSub = <OptimizerSiteSummary kind="Exit Node" sites={useLiveData && !data ? undefined : ex.map(e=>e.site)} />
return [
{
label: "Home",
value: useLiveData && !data ? "—" : homeCount,
sub: useLiveData && !data ? "Количество WAN неизвестно" : `${wanCount} WAN-аплинков`,
icon: <MonitorIcon aria-hidden="true" className="size-4" />,
},
{
label: "JumpHost",
value: useLiveData && !data ? "—" : jh.length,
sub: jhSub,
icon: <ServerIcon aria-hidden="true" className="size-4" />,
},
{
label: "Exit Node",
value: useLiveData && !data ? "—" : ex.length,
sub: exSub,
icon: <NetworkIcon aria-hidden="true" className="size-4" />,
},
{
label: "Рекомендации",
value: useLiveData && !data ? "—" : totalSwitches,
sub: useLiveData && !data ? "Расчёт пока неизвестен" : totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
icon: <ZapIcon aria-hidden="true" className="size-4" />,
},
]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
function applyAll() {
if (useLiveData) return
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(writeLock.current || !can("mm:network:write") || !useLiveData || !ospfServerId || loading || error || !data || !previewReady || ospfMetaError || !ospfPreview?.changedCount || !liveServers.some(server=>String(server.id)===ospfServerId&&server.enabled))return
const owner=previewKey,target=ospfServerId,weight=settings.pingWeight
const current=()=>reader.current.alive && activeContext.current===owner
writeLock.current=true;setOspfApplying(true);setApplyError({owner,message:""})
try {
const raw=await apiFetch<unknown>("/api/servers/"+target+"/ospf/optimize",{method:"POST",body:JSON.stringify({pingWeight:weight})})
const parsed=ospfOptimizeSchema.safeParse(raw)
if(!parsed.success || String(parsed.data.serverId)!==target || parsed.data.pingWeight!==weight)throw new Error("Применение OSPF не подтверждено: недостоверный ответ или другой контекст")
if(current())setApplyResult({owner,serverName:parsed.data.serverName,optimizedCount:parsed.data.optimizedCount,receivedAt:new Date().toISOString()})
}catch(error){
if(current())setApplyError({owner,message:error instanceof Error?error.message:"Ошибка применения OSPF"})
}finally{
writeLock.current=false
if(reader.current.alive){
setOspfApplying(false)
setPreviewState(previous=>({...previous,pending:true}))
setPreviewTick(value=>value+1)
void load()
}
}
}
const set = <K extends keyof OptimizerSettings>(k: K, v: OptimizerSettings[K]) =>
setSettings(prev => ({ ...prev, [k]: v }))
useEffect(()=>{
if(!useLiveData||!ospfEligible)return
let cancelled=false
void apiFetch<unknown>("/api/ospf/catalog").then(raw=>{
if(cancelled)return
const parsed=ospfCatalogSchema.safeParse(raw)
if(!parsed.success)throw new Error("Недостоверный каталог OSPF")
const catalog=parsed.data
const failure=catalog.failures.find(item=>String(item.serverId)===ospfServerId)
if(failure)throw new Error(failure.error)
const interfaces=catalog.interfaces.filter(item=>String(item.serverId)===ospfServerId && !/^\(ref\s+\*.+\)$/.test(item.interface.trim()))
const selected=liveServers.find(item=>String(item.id)===ospfServerId)
setMetaState({owner:ospfServerId,data:{interfaces:interfaces.length,areas:new Set(interfaces.map(item=>item.areaId)).size,serverName:selected?.name||selected?.host||ospfServerId},error:""})
}).catch(error=>{
if(!cancelled)setMetaState(previous=>({owner:ospfServerId,data:previous.owner===ospfServerId?previous.data:null,error:error instanceof Error?error.message:"Ошибка каталога OSPF"}))
})
return()=>{cancelled=true}
},[apiFetch,liveServers,ospfServerId,ospfEligible,useLiveData,previewTick])
useEffect(()=>{
if(!useLiveData||!ospfEligible)return
let cancelled=false
queueMicrotask(()=>{
if(cancelled)return
setPreviewState(previous=>({owner:previewKey,data:previous.owner===previewKey?previous.data:null,error:"",pending:true}))
void apiFetch<unknown>("/api/servers/"+ospfServerId+"/ospf/optimize/preview",{method:"POST",body:JSON.stringify({pingWeight:settings.pingWeight})}).then(raw=>{
if(cancelled)return
const preview=optimizerOspfPreviewSchema.safeParse(raw)
if(!preview.success||String(preview.data.serverId)!==ospfServerId||preview.data.pingWeight!==settings.pingWeight)throw new Error("Недостоверный preview OSPF или другой контекст")
setPreviewState({owner:previewKey,data:preview.data,error:"",pending:false})
}).catch(error=>{
if(!cancelled)setPreviewState(previous=>({owner:previewKey,data:previous.owner===previewKey?previous.data:null,error:error instanceof Error?error.message:"Ошибка preview OSPF",pending:false}))
})
})
return()=>{cancelled=true}
},[apiFetch,ospfServerId,ospfEligible,settings.pingWeight,useLiveData,previewKey,readAt,previewTick])
return (
<div className="flex flex-col h-full min-h-0 min-w-0">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Оптимизатор маршрутов" }]}
actions={
<>
{!useLiveData && totalSwitches > 0 && (
<Button type="button" size="sm" onClick={applyAll}>
<ZapIcon aria-hidden="true" className="size-4" />Симулировать все ({totalSwitches})
</Button>
)}
<Button type="button" variant="outline" size="sm" onClick={() => void load()} disabled={loading || ospfApplying}>
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
{loading ? "Расчёт…" : "Обновить"}
</Button>
</>
}
/>
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-4 md:p-6">
<fieldset disabled={ospfApplying} className="min-w-0">
<div className="flex flex-col gap-4">
{/* Status row */}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap px-0.5">
<TopologyBar />
<span className="text-border">·</span>
<span>Расчёт: {data?.updatedAt ?? "—"}</span>
{readAt && <span role="status">Ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время браузера, не измерения.</span>}
<span className="text-border">·</span>
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
<Chip>{useLiveData ? "Данные API · расчёт модели" : "Демо · симуляция"}</Chip>
{useLiveData && backendStatus === false && (
<span className="text-warning ">
бекенд не отвечает на /health — проверьте URL в настройках
</span>
)}
</div>
<p role="status" className="text-xs text-muted-foreground">Community — расчёт модели. Применение Community к устройству недоступно.</p>
<details className="text-xs text-muted-foreground"><summary className="cursor-pointer rounded-sm focus-visible:ring-2 focus-visible:ring-ring">Как читать расчёт</summary><p className="mt-2">Индекс в баллах оценивает модель; базовая цепочка строится из топологии. Ping и скорость могут включать оценки по задержке серверов и лимитам WAN. Скорость пробы относится к паре устройств; сопоставление с WAN расчётное. Исходный интерфейс используется для Ping после теста, а путь теста скорости выбирает маршрутизация устройства. ECMP, RPF и VRF служат локальному просмотру. OSPF применяется отдельным действием ниже.</p></details>
<KpiStatGrid gridClassName="grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
aria-label="Сводка оптимизатора"
items={statsChips.map((s, i) => ({
id: `ro-${i}`,
label: s.label,
value: s.value,
hint: s.sub,
icon: s.icon,
iconClassName: s.label === "Рекомендации" && totalSwitches > 0
? "text-warning"
: s.label === "Exit Node"
? "text-success"
: s.label === "JumpHost"
? "text-primary"
: "text-muted-foreground",
variant: s.label === "Рекомендации" && totalSwitches > 0 ? "warning" as const : "default" as const,
}))}
/>
{error && (
<div role="alert" className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
<AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}. {data ? "Последний полный снимок; актуальность не подтверждена." : "Данные оптимизатора неизвестны."}
</div>
)}
{loading && !data && (
<div role="status" className="text-muted-foreground text-sm py-8 text-center animate-pulse">
Расчёт оптимальных маршрутов…
</div>
)}
{/* Settings */}
<Frame dense className="w-full">
<FramePanel className="p-0">
<button type="button" aria-expanded={showSettings} aria-label="Настройки оптимизатора" disabled={ospfApplying}
className={cn("flex flex-wrap w-full items-center gap-3 px-4 py-3 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", showSettings && "border-b")}
onClick={() => setShowSettings(v => !v)}
>
<SettingsIcon aria-hidden="true" className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1">Настройки оптимизатора</span>
<span className="text-[11px] text-muted-foreground mr-2">
порог {settings.switchThreshold} б. · ping {settings.pingWeight}% · обновление {settings.probeIntervalMin} мин
</span>
{showSettings ? <ChevronUpIcon aria-hidden="true" className="size-4 text-muted-foreground" /> : <ChevronDownIcon aria-hidden="true" className="size-4 text-muted-foreground" />}
</button>
{showSettings && (
<div className="py-4 px-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="flex flex-col gap-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
<SettingRow label="Мин. выигрыш" unit="б.">
<NInput label="Порог переключения" value={settings.switchThreshold} onChange={v => set("switchThreshold", v)} min={0} max={50} />
</SettingRow>
<SettingRow label="Гистерезис" unit="б.">
<NInput disabled label="Порог гистерезиса" value={settings.hysteresisThreshold} onChange={v => set("hysteresisThreshold", v)} min={0} max={50} />
</SettingRow>
<p className="text-[10px] text-muted-foreground">Гистерезис не участвует в этом расчёте.</p>
</div>
<div className="flex flex-col gap-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Веса метрик</p>
<SettingRow label="Вес задержки" unit="%">
<NInput label="Вес задержки" value={settings.pingWeight} onChange={v => set("pingWeight", Math.min(100, v))} min={0} max={100} />
</SettingRow>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-info transition-all" style={{ width: `${settings.pingWeight}%` }} />
</div>
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>Ping {settings.pingWeight}%</span>
<span>BW {100 - settings.pingWeight}%</span>
</div>
<SettingRow label="Обновление расчёта">
<div className="flex rounded-md border border-input overflow-hidden h-7">
{[5, 10, 15, 30].map(v => (
<button type="button" aria-pressed={settings.probeIntervalMin===v} key={v} onClick={() => set("probeIntervalMin", v)}
className={cn("px-2.5 text-xs transition-colors cursor-pointer",
settings.probeIntervalMin === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
{v}м
</button>
))}
</div>
</SettingRow>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Автоприменение Community недоступно</p>
<p className="text-xs text-muted-foreground">Настройки расчёта сохраняются в этом браузере. Для сетевых изменений используйте профильные разделы.</p>
</div>
</div>
<div className="flex justify-end gap-2 mt-5 pt-4 border-t">
<Button type="button" variant="outline" size="sm" onClick={() => setSettings({ ...DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS })}>Сбросить</Button>
<Button type="button" size="sm" onClick={() => void load(settings)}>
<CheckCircleIcon aria-hidden="true" className="size-4" />Пересчитать
</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-3">
Базовые значения совпадают с разделом{" "}
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки → Route AI</Link>
.
</p>
</div>
)}
</FramePanel>
</Frame>
{ospfMetaError && <p role="alert" className="text-sm text-warning">Каталог OSPF: {ospfMetaError}. Актуальность неизвестна.</p>}
{/* OSPF optimization from Route Optimizer */}
<OpsPanel
title="OSPF"
description={`Route AI weight: ping ${settings.pingWeight}%`}
contentClassName="px-5 py-4 flex flex-col gap-2.5"
>
{!useLiveData && (
<p className="text-xs text-muted-foreground">
Доступно только в режиме живых данных.
</p>
)}
{useLiveData && <p className="text-xs text-muted-foreground">Ping и скорость в preview могут включать расчётные значения. Они не подтверждают текущее качество соединения.</p>}
{useLiveData && contextError && <p role="alert" className="text-xs text-destructive">{contextError}</p>}
{useLiveData && (
<>
<div className="rounded-lg border bg-muted/15 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b bg-background/80 flex-wrap">
<select aria-label="Устройство OSPF"
className="w-full min-w-0 sm:w-auto sm:min-w-64 text-xs bg-background text-foreground border border-input rounded-md px-2 py-1 h-7 focus:outline-none focus:ring-1 focus:ring-ring"
value={ospfServerId}
onChange={(e) => onSelectServer(e.target.value)}
>
{requestedServer !== null && !ospfEligible && <option value={requestedServer}>Недоступное устройство</option>}
{liveServers.length === 0 && requestedServer === null && <option value="">Нет доступных серверов</option>}
{liveServers.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name || s.host} ({s.host})
</option>
))}
</select>
<span className="text-[11px] text-muted-foreground">
{contextError ? "Устройство недоступно" : ospfMetaError ? "Каталог OSPF недостоверен" : ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
</span>
<Button type="button"
size="sm"
className="ml-auto h-7 text-xs"
onClick={() => void applyOspfOptimization()}
disabled={!can("mm:network:write") || loading || Boolean(error) || !data || !ospfEligible || ospfApplying || !previewReady || Boolean(ospfMetaError) || !ospfPreview?.changedCount}
>
<ZapIcon aria-hidden="true" className={cn("size-3.5", ospfApplying && "animate-pulse")} />
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
</Button>
</div>
<div className="px-3 py-2 text-xs grid grid-cols-1 md:grid-cols-4 gap-2">
<div className="text-muted-foreground">Router</div>
<div className="md:col-span-2 font-mono truncate">{ospfMeta?.serverName ?? "—"}</div>
<div className="text-right text-muted-foreground">
apply: {ospfApplyResult?.optimizedCount ?? "—"}
</div>
</div>
</div>
<div className="rounded-lg border bg-background/70 overflow-hidden">
<div className="px-3 py-2 border-b text-[11px] text-muted-foreground flex flex-wrap gap-2 items-center justify-between">
<span>Preview изменений OSPF cost (до применения)</span>
<Button type="button" variant="outline" size="sm" disabled={!ospfEligible || ospfApplying || ospfPreviewLoading} onClick={()=>setPreviewTick(value=>value+1)}>Обновить preview OSPF</Button>
<span>
{ospfPreviewLoading
? "расчёт…"
: ospfPreview
? `${ospfPreview.changedCount} из ${ospfPreview.interfacesTotal} изменятся`
: "нет данных"}
</span>
</div>
<RouteOptimizerOspfPreviewDataGrid
key={previewKey}
rows={ospfPreview?.interfaces ?? []}
error={ospfPreviewError || null}
loading={ospfPreviewLoading}
/>
</div>
{ospfApplying && <p role="status" className="text-xs text-muted-foreground">Ожидание подтверждения OSPF API…</p>}
{!can("mm:network:write") && <p className="text-xs text-muted-foreground">Доступен просмотр OSPF. Для применения требуется право изменения сети.</p>}
{ospfApplyResult && (
<div role="status" className="text-xs rounded-md border border-success/30 bg-success/10 px-3 py-2 text-success ">
Последнее подтверждение API: {ospfApplyResult.serverName}, изменено интерфейсов — {ospfApplyResult.optimizedCount}. Получено {new Date(ospfApplyResult.receivedAt).toLocaleTimeString("ru-RU")}.
</div>
)}
{ospfApplyError && (
<div role="alert" className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
Ошибка OSPF-оптимизации: {ospfApplyError}
</div>
)}
</>
)}
</OpsPanel>
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* ECMP Card */}
<OpsPanel
title="ECMP"
description="Equal-Cost Multi-Path"
headerRight={
<button type="button" role="switch" aria-label="Черновик ECMP" aria-checked={ecmpEnabled}
onClick={() => setEcmpEnabled(v => !v)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
ecmpEnabled ? "bg-focus" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-primary-foreground shadow-sm transition-transform",
ecmpEnabled ? "translate-x-4" : "translate-x-0")} />
</button>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<fieldset disabled={!ecmpEnabled} className="flex min-w-0 flex-col gap-3">
<legend className="sr-only">Параметры черновика ECMP</legend>
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Макс. путей</span>
<div className="flex items-center gap-2">
<input aria-label="Максимальное число путей ECMP" disabled={!ecmpEnabled} type="range" min={1} max={64} step={1} value={ecmpMaxPaths}
onChange={e => setEcmpMaxPaths(Number(e.target.value))}
className="flex-1 accent-focus h-1.5" />
<span className="text-sm font-mono w-8 text-right">{ecmpMaxPaths}</span>
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Алгоритм балансировки</span>
<div className="flex rounded-md border border-input overflow-hidden h-7 text-xs">
{([["per-dst","По dst"],["per-conn","По conn"],["per-packet","По пакет"]] as const).map(([v, l]) => (
<button type="button" disabled={!ecmpEnabled} aria-pressed={ecmpAlgo===v} key={v} onClick={() => setEcmpAlgo(v)}
className={cn("flex-1 transition-colors cursor-pointer",
ecmpAlgo === v ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted")}>
{l}
</button>
))}
</div>
</div>
<div className="flex items-start gap-2 rounded-lg border border-focus/20 bg-focus/5 px-2.5 py-2 text-[11px] text-focus ">
<InfoIcon aria-hidden="true" className="size-3 shrink-0 mt-0.5" />
<span>Черновой сценарий ECMP для сравнения настроек.</span>
</div>
</fieldset>
</OpsPanel>
{/* RPF Card */}
<OpsPanel title="RPF" description="Reverse Path Forwarding" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">Режим проверки источника</span>
<div className="flex flex-col gap-1.5">
{([
{ v: "disabled", l: "Отключён", d: "Проверка не выполняется", cls: "text-muted-foreground" },
{ v: "loose", l: "Loose", d: "Маршрут к источнику существует", cls: "text-warning" },
{ v: "strict", l: "Strict", d: "Пакет пришёл по лучшему маршруту", cls: "text-success" },
] as const).map(({ v, l, d, cls }) => (
<button key={v} type="button" aria-pressed={rpfMode===v} onClick={() => setRpfMode(v)}
className={cn(
"flex items-start gap-2.5 px-3 py-2 rounded-lg border text-left transition-all text-xs",
rpfMode === v
? "border-warning/30 bg-warning/5"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/30",
)}>
<div className={cn("size-3.5 rounded-full border-2 shrink-0 mt-0.5 transition-colors",
rpfMode === v ? "border-warning bg-warning" : "border-muted-foreground")} />
<div>
<div className={cn("font-medium", rpfMode === v ? cls : "text-foreground")}>{l}</div>
<div className="text-muted-foreground text-[10px] mt-0.5">{d}</div>
</div>
</button>
))}
</div>
</div>
{rpfMode !== "disabled" && (
<div className="flex items-start gap-2 rounded-lg border border-warning/20 bg-warning/5 px-2.5 py-2 text-[11px] text-warning ">
<InfoIcon aria-hidden="true" className="size-3 shrink-0 mt-0.5" />
<span><code className="font-mono">/ip settings set rp-filter={rpfMode}</code></span>
</div>
)}
</OpsPanel>
{/* VRF Card */}
<OpsPanel title="VRF" description="Virtual Routing" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
<div className="flex flex-col gap-1.5">
{VRF_NAMES.map(vrf => {
const isSel = selectedVrf === vrf
const isMain = vrf === "main"
return (
<button key={vrf} type="button" aria-pressed={selectedVrf===vrf} onClick={() => setSelectedVrf(vrf)}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-lg border text-left transition-all text-xs",
isSel
? "border-info/40 bg-info/8 text-info"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/30 text-muted-foreground hover:text-foreground",
)}>
<div className={cn("size-2.5 rounded-full shrink-0",
isSel ? "bg-info" : "bg-muted-foreground/30")} />
<span className="font-mono flex-1">{vrf}</span>
{isMain && (
<span className="text-[10px] border border-current rounded px-1 opacity-60">default</span>
)}
</button>
)
})}
</div>
</div>
<div className="flex items-start gap-2 rounded-lg border border-info/20 bg-info/5 px-2.5 py-2 text-[11px] text-info ">
<InfoIcon aria-hidden="true" className="size-3 shrink-0 mt-0.5" />
<span>Выбран черновой контекст VRF <code className="font-mono">{selectedVrf}</code></span>
</div>
</OpsPanel>
</div>
{/* Per-home-router cards */}
{data && <OptimizerHomesList homes={data.homes}>{entry => (
<HomeRouterCard
key={entry.home.id}
entry={entry}
jumpHosts={jumpHostsForCards}
pinned={pinned}
applied={applied}
applying={applying}
onPin={togglePin}
onApply={applyRec}
simulation={!useLiveData}
/>
)}</OptimizerHomesList>}
</div>
</fieldset>
</div>
</div>
)
}