1079 lines
57 KiB
TypeScript
1079 lines
57 KiB
TypeScript
"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>
|
||
)
|
||
}
|