"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 */}
{home.label}
{home.site}
{home.model}
{home.wans.slice(0,3).map(w => `${w.name} (${w.isp} · ${w.ip})`).join(" · ")}
{home.wans.length>3&&
Ещё {home.wans.length-3} WAN. Все {home.wans.length} аплинков — во вкладке WAN × JH.
}
{/* 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 => (
))}
{home.wans.length === 0 && Нет WAN-аплинков для расчёта. Настроить сервер
}
{/* Tab content */}
{tab === "wan-matrix" && (
)}
{tab === "full-routes" && (
)}
{tab === "bgp-community" && (
)}
)
}
// ─── Topology legend ──────────────────────────────────────────────────────────
function TopologyBar() {
return (
Схема: Home Router
→
WAN-аплинк
→
JumpHost
→
Exit Node
)
}
// ════════════════════════════════════════════════════════════════════════════
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: ,
},
{
label: "JumpHost",
value: useLiveData && !data ? "—" : jh.length,
sub: jhSub,
icon: ,
},
{
label: "Exit Node",
value: useLiveData && !data ? "—" : ex.length,
sub: exSub,
icon: ,
},
{
label: "Рекомендации",
value: useLiveData && !data ? "—" : totalSwitches,
sub: useLiveData && !data ? "Расчёт пока неизвестен" : totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
icon: ,
},
]
}, [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("/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: K, v: OptimizerSettings[K]) =>
setSettings(prev => ({ ...prev, [k]: v }))
useEffect(()=>{
if(!useLiveData||!ospfEligible)return
let cancelled=false
void apiFetch("/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("/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 (
{!useLiveData && totalSwitches > 0 && (
)}
>
}
/>
)
}