"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(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${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 ( {children} ) } function ProbChip({ prob, best }: { prob: number; best?: boolean }) { return ( Индекс модели: {prob} б. ) } function ConfChip({ conf }: { conf: string }) { const map: Record = { 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 Категория модели: {conf} } function NInput({ value, onChange, min, max, label, disabled }: { disabled?: boolean; label: string; 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}}
) } // ─── 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 applied: Set applying: Set onPin: (k: string) => void onApply: (comm: string, homeId: string) => void simulation: boolean }) { 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 */}
{/* 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 => ( ))}
{home.wans.length === 0 &&

Нет WAN-аплинков для расчёта. Настроить сервер

} {/* Tab content */} {tab === "wan-matrix" && ( )} {tab === "full-routes" && ( )} {tab === "bgp-community" && ( )}
) } // ─── Topology legend ────────────────────────────────────────────────────────── function TopologyBar() { return (
→
→
→
) } // ════════════════════════════════════════════════════════════════════════════ export default function RouteOptimizerPage() { return Загрузка контекста оптимизатора…

}>
} 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 } 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(null) const [error, setError] = useState("") const [readAt, setReadAt] = useState(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(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=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(path:string, parse:(raw:unknown)=>T, label:string) { let raw:unknown try { raw = await apiFetch(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>()) const pendingSimulations = useRef(new Set()) 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 = j.site)} /> const exSub = e.site)} /> return [ { label: "Home", value: useLiveData && !data ? "—" : homeCount, sub: useLiveData && !data ? "Количество WAN неизвестно" : `${wanCount} WAN-аплинков`, icon: