Updated the API fetching mechanism in various components to utilize the new requestJson function for improved consistency and error handling. This change affects the alerts, dashboard, data collection, filters, gre, network map, probes, recursive routes, route optimizer, servers, settings, traffic, and uptime pages.
1491 lines
67 KiB
TypeScript
1491 lines
67 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||
import Link from "next/link"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Flag } from "@/components/flag"
|
||
import { cn } from "@/lib/utils"
|
||
import { 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,
|
||
type RouteOptimizerSpeedProbe,
|
||
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, PinIcon,
|
||
ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
|
||
MonitorIcon, ServerIcon, GitMergeIcon, ShieldIcon, LayersIcon,
|
||
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.id}-${ex.id}`]
|
||
if (!base) continue
|
||
jhExLegs.push({
|
||
jhId: jh.id, exitId: ex.id,
|
||
pingMs: jitter(base.ping, 4), dlMbps: jitter(base.dl, 30), ulMbps: jitter(base.ul, 25),
|
||
})
|
||
}
|
||
}
|
||
|
||
function getJhEx(jhId: string, exitId: string) {
|
||
return jhExLegs.find(l => l.jhId === jhId && l.exitId === exitId)!
|
||
}
|
||
|
||
const homes: HomeEntry[] = HOME_ROUTERS.map((home) => {
|
||
// (WAN × JH) legs
|
||
const wanJhLegs: WanJhLeg[] = []
|
||
for (const wan of home.wans) {
|
||
for (const jh of JUMPHOSTS) {
|
||
const base = HW_BASE[`${wan.id}-${jh.id}`]
|
||
if (!base) continue
|
||
const ping = jitter(base.ping, 3)
|
||
const dl = Math.min(wan.maxDl, jitter(base.dl, 20))
|
||
const ul = Math.min(wan.maxUl, jitter(base.ul, 15))
|
||
wanJhLegs.push({
|
||
wanId: wan.id, jhId: jh.id, pingMs: ping, dlMbps: dl, ulMbps: ul,
|
||
loss: base.loss > 0 ? +(base.loss + (Math.random() - 0.5) * 0.5).toFixed(1) : 0,
|
||
score: 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 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-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||
: "bg-muted text-muted-foreground border-border"
|
||
}>{prob}%</Chip>
|
||
)
|
||
}
|
||
|
||
function ConfChip({ conf }: { conf: string }) {
|
||
const map: Record<string, string> = {
|
||
HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||
MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||
LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||
}
|
||
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
|
||
}
|
||
|
||
function LossChip({ loss }: { loss: number }) {
|
||
if (loss === 0) return <span className="text-emerald-600 dark:text-emerald-400 text-[11px] font-mono">0%</span>
|
||
return (
|
||
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-red-500" : "text-amber-500")}>
|
||
{loss}%
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<button type="button" onClick={() => onChange(!checked)}
|
||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
||
checked ? "bg-primary" : "bg-input")}>
|
||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||
checked ? "translate-x-4" : "translate-x-0")} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||
return (
|
||
<Input 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>
|
||
)
|
||
}
|
||
|
||
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
|
||
// Rows = WANs, Columns = JHs, cells show ping / bw / score
|
||
|
||
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
|
||
home: HomeRouter
|
||
legs: WanJhLeg[]
|
||
jumpHosts: JumpHost[]
|
||
pw: number
|
||
}) {
|
||
// find best leg overall
|
||
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
|
||
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
|
||
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
|
||
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
|
||
{jumpHosts.map(jh => (
|
||
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
|
||
<div>{jh.label}</div>
|
||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||
<Flag code={jh.country} />
|
||
{jh.site} · {jh.ip}
|
||
</div>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{home.wans.map(wan => (
|
||
<tr key={wan.id} className="hover:bg-muted/30 transition-colors">
|
||
{/* WAN name */}
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||
<div>
|
||
<p className="font-mono text-xs font-semibold">{wan.name}</p>
|
||
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
{/* ISP */}
|
||
<td className="px-3 py-3">
|
||
<p className="text-xs font-medium">{wan.isp}</p>
|
||
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
|
||
</td>
|
||
{/* Max bandwidth */}
|
||
<td className="px-3 py-3 text-right">
|
||
<p className="font-mono text-xs">↓{wan.maxDl}</p>
|
||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
|
||
</td>
|
||
{/* Per-JH cells */}
|
||
{jumpHosts.map(jh => {
|
||
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
|
||
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs">—</td>
|
||
const isBest = leg.score === bestScore
|
||
return (
|
||
<td key={jh.id} className={cn(
|
||
"px-3 py-3 text-center",
|
||
isBest && "bg-emerald-500/5",
|
||
)}>
|
||
<div className={cn(
|
||
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
|
||
isBest
|
||
? "border border-emerald-500/20 bg-emerald-500/8"
|
||
: "border border-transparent",
|
||
)}>
|
||
{isBest && (
|
||
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
|
||
★ ЛУЧШИЙ
|
||
</span>
|
||
)}
|
||
<span className={cn("font-mono text-xs font-semibold",
|
||
leg.pingMs < 10 ? "text-emerald-600 dark:text-emerald-400"
|
||
: leg.pingMs < 25 ? "text-foreground"
|
||
: "text-amber-600 dark:text-amber-400"
|
||
)}>
|
||
{leg.pingMs} мс
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground font-mono">
|
||
↓{leg.dlMbps} ↑{leg.ulMbps}
|
||
</span>
|
||
<div className="flex items-center gap-1.5 mt-0.5">
|
||
<span className="text-[10px] font-mono text-foreground/70">
|
||
score {leg.score}
|
||
</span>
|
||
{leg.loss > 0 && <LossChip loss={leg.loss} />}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
)
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Full routes table ────────────────────────────────────────────────────────
|
||
|
||
function FullRoutesTable({ routes, bestId }: { routes: FullRoute[]; bestId?: string }) {
|
||
const [expanded, setExpanded] = useState(false)
|
||
const visible = expanded ? routes : routes.slice(0, 5)
|
||
|
||
return (
|
||
<div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||
<th className="text-left font-medium px-4 py-2"># Маршрут</th>
|
||
<th className="text-left font-medium px-3 py-2">WAN → JH</th>
|
||
<th className="text-left font-medium px-3 py-2">JH → Exit</th>
|
||
<th className="text-center font-medium px-3 py-2">Ping (итого)</th>
|
||
<th className="text-center font-medium px-3 py-2">BW (мин)</th>
|
||
<th className="text-center font-medium px-3 py-2">Score</th>
|
||
<th className="text-center font-medium px-3 py-2">P(opt)</th>
|
||
<th className="text-center font-medium px-3 py-2">Conf.</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border/60">
|
||
{visible.map((r, i) => {
|
||
const isBest = r.id === bestId || i === 0
|
||
const totalPing = r.hw.pingMs + r.je.pingMs
|
||
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
|
||
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
|
||
return (
|
||
<tr key={r.id} className={cn(
|
||
"hover:bg-muted/30 transition-colors",
|
||
isBest && "bg-emerald-500/5",
|
||
)}>
|
||
<td className="px-4 py-2.5">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[10px] font-mono text-muted-foreground w-4">{i + 1}</span>
|
||
{isBest && (
|
||
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
|
||
Лучший
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2.5">
|
||
<div className="flex items-center gap-1.5 text-xs">
|
||
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
|
||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||
<div>
|
||
<div className="font-medium">{r.jh.label}</div>
|
||
<div className="font-mono text-[10px] text-muted-foreground">{r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2.5">
|
||
<div className="flex items-center gap-1.5 text-xs">
|
||
<div>
|
||
<div className="flex items-center gap-1 font-medium">
|
||
<Flag code={r.exit.country} />
|
||
{r.exit.label}
|
||
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
|
||
</div>
|
||
<div className="font-mono text-[10px] text-muted-foreground">{r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className={cn("px-3 py-2.5 text-center font-mono text-xs",
|
||
totalPing < 40 ? "text-emerald-600 dark:text-emerald-400"
|
||
: totalPing < 80 ? "text-amber-600 dark:text-amber-400"
|
||
: "text-red-500"
|
||
)}>
|
||
{totalPing} мс
|
||
</td>
|
||
<td className="px-3 py-2.5 text-center font-mono text-xs text-muted-foreground">
|
||
<div>↓{minDl}</div>
|
||
<div>↑{minUl}</div>
|
||
</td>
|
||
<td className="px-3 py-2.5 text-center font-mono text-xs font-semibold">{r.score}</td>
|
||
<td className="px-3 py-2.5 text-center"><ProbChip prob={r.probabilityOptimal} best={isBest} /></td>
|
||
<td className="px-3 py-2.5 text-center"><ConfChip conf={r.confidence} /></td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{routes.length > 5 && (
|
||
<button onClick={() => setExpanded(v => !v)}
|
||
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1">
|
||
{expanded
|
||
? <><ChevronUpIcon className="size-3" />Свернуть</>
|
||
: <><ChevronDownIcon className="size-3" />Показать все {routes.length} комбинаций</>}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Community recs table ─────────────────────────────────────────────────────
|
||
|
||
function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply, threshold: _threshold }: {
|
||
recs: CommRec[]
|
||
homeId: string
|
||
pinned: Set<string>
|
||
applied: Set<string>
|
||
applying: Set<string>
|
||
onPin: (k: string) => void
|
||
onApply: (comm: string, homeId: string) => void
|
||
threshold: number
|
||
}) {
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||
<th className="text-left font-medium px-4 py-2">Community</th>
|
||
<th className="text-left font-medium px-3 py-2">Текущий (WAN → JH → Exit)</th>
|
||
<th className="text-left font-medium px-3 py-2">Рекомендуемый</th>
|
||
<th className="text-center font-medium px-3 py-2">P(тек / рек)</th>
|
||
<th className="text-right font-medium px-3 py-2">Действие</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{recs.map((r, idx) => {
|
||
const pinKey = `${homeId}::${r.community}`
|
||
const isPinned = pinned.has(pinKey)
|
||
const isApplied = applied.has(pinKey)
|
||
const isApplying = applying.has(pinKey)
|
||
const canApply = r.shouldSwitch && !isPinned && !isApplied
|
||
|
||
return (
|
||
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
|
||
"hover:bg-muted/30 transition-colors",
|
||
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
|
||
isApplied && "bg-emerald-500/5",
|
||
)}>
|
||
{/* community */}
|
||
<td className="px-4 py-2.5">
|
||
<div className="font-mono text-xs font-medium">{r.community}</div>
|
||
<div className="text-[11px] text-muted-foreground">{r.communityName}</div>
|
||
</td>
|
||
|
||
{/* current route */}
|
||
<td className="px-3 py-2.5">
|
||
{r.current ? (
|
||
<div className="text-xs flex items-center gap-1 flex-wrap">
|
||
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">{r.current.wan}</span>
|
||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||
<span>{r.current.jh}</span>
|
||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||
<span className="text-muted-foreground">{r.current.exit}</span>
|
||
<span className="font-mono text-[10px] text-muted-foreground">({r.current.gateway})</span>
|
||
</div>
|
||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||
</td>
|
||
|
||
{/* recommended */}
|
||
<td className="px-3 py-2.5">
|
||
{r.recommended ? (
|
||
<div className={cn("text-xs flex items-center gap-1 flex-wrap",
|
||
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400")}>
|
||
<span className="font-mono font-medium">{r.recommended.wan}</span>
|
||
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||
<span>{r.recommended.jh}</span>
|
||
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||
<span>{r.recommended.exit}</span>
|
||
{r.shouldSwitch && !isPinned && (
|
||
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
|
||
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||
</td>
|
||
|
||
{/* probability */}
|
||
<td className="px-3 py-2.5 text-center">
|
||
<div className="flex items-center justify-center gap-1">
|
||
<ProbChip prob={r.current?.prob ?? 0} />
|
||
<span className="text-muted-foreground text-[10px]">/</span>
|
||
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !isPinned} />
|
||
</div>
|
||
</td>
|
||
|
||
{/* actions */}
|
||
<td className="px-3 py-2.5">
|
||
<div className="flex items-center justify-end gap-1.5">
|
||
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
|
||
<Button variant="outline" size="sm"
|
||
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
|
||
onClick={() => onPin(pinKey)}>
|
||
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
|
||
{isPinned ? "Открепить" : "Закрепить"}
|
||
</Button>
|
||
{canApply && (
|
||
<Button size="sm" className="h-7 text-xs" disabled={isApplying}
|
||
onClick={() => onApply(r.community, homeId)}>
|
||
{isApplying
|
||
? <RefreshCwIcon className="size-3 animate-spin" />
|
||
: <PlayIcon className="size-3" />}
|
||
Применить
|
||
</Button>
|
||
)}
|
||
{isApplied && (
|
||
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||
<CheckCircleIcon className="size-3" />Применено
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Home Router card ─────────────────────────────────────────────────────────
|
||
|
||
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
|
||
|
||
function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying, onPin, onApply }: {
|
||
entry: HomeEntry
|
||
jumpHosts: JumpHost[]
|
||
settings: OptimizerSettings
|
||
pinned: Set<string>
|
||
applied: Set<string>
|
||
applying: Set<string>
|
||
onPin: (k: string) => void
|
||
onApply: (comm: string, homeId: string) => void
|
||
}) {
|
||
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 (
|
||
<Card className="gap-0 py-0 overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
|
||
<MonitorIcon className="size-4 text-muted-foreground shrink-0" />
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<Flag code={home.country} />
|
||
<span className="text-sm font-semibold font-mono">{home.label}</span>
|
||
<span className="text-xs text-muted-foreground">{home.site}</span>
|
||
<span className="text-[10px] text-muted-foreground border border-border rounded px-1.5 py-0.5 font-mono">{home.model}</span>
|
||
</div>
|
||
<div className="text-[11px] text-muted-foreground font-mono mt-0.5">
|
||
{home.wans.map(w => `${w.name} (${w.isp} · ${w.ip})`).join(" · ")}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Best route summary */}
|
||
{bestRoute && (
|
||
<div className="flex items-center gap-1.5 ml-2 text-xs text-muted-foreground">
|
||
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{bestRoute.wan.name}</span>
|
||
<ArrowRightIcon className="size-3" />
|
||
<span>{bestRoute.jh.label}</span>
|
||
<ArrowRightIcon 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">
|
||
{switchCount > 0 && (
|
||
<Chip color="bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
|
||
{switchCount} переключений
|
||
</Chip>
|
||
)}
|
||
{bestRoute && (
|
||
<>
|
||
<ConfChip conf={bestRoute.confidence} />
|
||
<ProbChip prob={bestRoute.probabilityOptimal} best />
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sub-tabs */}
|
||
<div className="flex items-center gap-0 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 key={t.id} onClick={() => setTab(t.id)}
|
||
className={cn(
|
||
"px-4 py-2 text-xs font-medium border-b-2 transition-colors -mb-px",
|
||
tab === t.id
|
||
? "border-foreground text-foreground"
|
||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Tab content */}
|
||
{tab === "wan-matrix" && (
|
||
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
|
||
)}
|
||
{tab === "full-routes" && (
|
||
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
|
||
)}
|
||
{tab === "bgp-community" && (
|
||
<CommRecsTable
|
||
recs={commRecs}
|
||
homeId={home.id}
|
||
pinned={pinned} applied={applied} applying={applying}
|
||
onPin={onPin} onApply={onApply}
|
||
threshold={settings.switchThreshold}
|
||
/>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
// ─── 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 className="size-3.5" />
|
||
<span>Home Router</span>
|
||
</div>
|
||
<span className="text-border">→</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<WifiIcon className="size-3.5 text-sky-500" />
|
||
<span>WAN1 / WAN2</span>
|
||
</div>
|
||
<span className="text-border">→</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<ServerIcon className="size-3.5 text-violet-500" />
|
||
<span>JumpHost</span>
|
||
</div>
|
||
<span className="text-border">→</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<NetworkIcon className="size-3.5 text-emerald-500" />
|
||
<span>Exit Node</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
export default function RouteOptimizerPage() {
|
||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||
/** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */
|
||
const useLiveData = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||
|
||
const [loading, setLoading] = useState(true)
|
||
const [data, setData] = useState<OptimizerData | null>(null)
|
||
const [error, setError] = useState("")
|
||
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, setOspfServerId] = useState("")
|
||
const [ospfApplying, setOspfApplying] = useState(false)
|
||
const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null)
|
||
const [ospfApplyError, setOspfApplyError] = useState("")
|
||
const [ospfMeta, setOspfMeta] = useState<{ interfaces: number; areas: number; serverName: string } | null>(null)
|
||
const [ospfPreviewError, setOspfPreviewError] = useState("")
|
||
const [ospfPreview, setOspfPreview] = useState<{
|
||
changedCount: number
|
||
interfacesTotal: number
|
||
interfaces: Array<{
|
||
interface: string
|
||
currentCost: number
|
||
optimalCost: number
|
||
score: number
|
||
pingMs: number
|
||
dlMbps: number
|
||
ulMbps: number
|
||
}>
|
||
changes: Array<{
|
||
interface: string
|
||
currentCost: number
|
||
optimalCost: number
|
||
score: number
|
||
pingMs: number
|
||
dlMbps: number
|
||
ulMbps: number
|
||
}>
|
||
} | null>(null)
|
||
|
||
const load = useCallback(async (override?: OptimizerSettings) => {
|
||
const s = override ?? settingsRef.current
|
||
setLoading(true)
|
||
setError("")
|
||
try {
|
||
if (!useLiveData) {
|
||
await new Promise((r) => setTimeout(r, 450))
|
||
setData(buildMockData(s))
|
||
setLiveJumpHosts([])
|
||
setLiveExitNodes([])
|
||
setLiveServers([])
|
||
return
|
||
}
|
||
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
|
||
const enabledRows = rows.filter((r) => r.enabled)
|
||
setLiveServers(enabledRows)
|
||
setOspfServerId((prev) => (prev && enabledRows.some((r) => String(r.id) === prev) ? prev : String(enabledRows[0]?.id ?? "")))
|
||
const { jumpHosts, exitNodes } = mapApiServersToTopology(rows)
|
||
setLiveJumpHosts(jumpHosts)
|
||
setLiveExitNodes(exitNodes)
|
||
|
||
let rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> | null = null
|
||
try {
|
||
const fr = await apiFetch<{ rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> }>(
|
||
"/api/filters/rules",
|
||
)
|
||
rulesets = fr.rulesets ?? null
|
||
} catch {
|
||
rulesets = null
|
||
}
|
||
let speedProbes: RouteOptimizerSpeedProbe[] = []
|
||
try {
|
||
const sp = await apiFetch<{ probes: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes")
|
||
speedProbes = sp.probes ?? []
|
||
} catch {
|
||
speedProbes = []
|
||
}
|
||
|
||
setData(buildLiveOptimizerData(rows, rulesets, s, speedProbes))
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||
setData(null)
|
||
setLiveJumpHosts([])
|
||
setLiveExitNodes([])
|
||
setLiveServers([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [apiFetch, useLiveData])
|
||
|
||
useEffect(() => {
|
||
if (!prefsLoaded) return
|
||
queueMicrotask(() => {
|
||
void load()
|
||
})
|
||
}, [load, useLiveData, prefsLoaded])
|
||
|
||
const pollMs = useMemo(() => {
|
||
if (!useLiveData) return 30_000
|
||
const m = Math.min(Math.max(settings.probeIntervalMin, 1), 30)
|
||
return m * 60_000
|
||
}, [useLiveData, settings.probeIntervalMin])
|
||
|
||
useEffect(() => {
|
||
if (!prefsLoaded) return
|
||
const id = setInterval(() => {
|
||
void load()
|
||
}, pollMs)
|
||
return () => clearInterval(id)
|
||
}, [load, pollMs, prefsLoaded])
|
||
|
||
function togglePin(key: string) {
|
||
setPinned(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n })
|
||
}
|
||
|
||
function applyRec(community: string, homeId: string) {
|
||
const key = `${homeId}::${community}`
|
||
setApplying(prev => new Set(prev).add(key))
|
||
setTimeout(() => {
|
||
setApplied(prev => new Set(prev).add(key))
|
||
setApplying(prev => { const n = new Set(prev); n.delete(key); return n })
|
||
}, 1200)
|
||
}
|
||
|
||
const totalSwitches = useMemo(
|
||
() =>
|
||
data?.homes.flatMap((h) =>
|
||
h.commRecs.filter(
|
||
(r) => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`),
|
||
),
|
||
).length ?? 0,
|
||
[data, pinned],
|
||
)
|
||
|
||
const jumpHostsForCards = useLiveData ? liveJumpHosts : JUMPHOSTS
|
||
|
||
const statsChips = useMemo(() => {
|
||
const homeCount = useLiveData ? (data?.homes.length ?? 0) : HOME_ROUTERS.length
|
||
const wanCount = useLiveData
|
||
? (data?.homes.reduce((s, h) => s + h.home.wans.length, 0) ?? 0)
|
||
: HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)
|
||
const jh = useLiveData ? liveJumpHosts : JUMPHOSTS
|
||
const ex = useLiveData ? liveExitNodes : EXIT_NODES
|
||
const jhSub = jh.length ? jh.map((j) => j.site).join(" · ") : "—"
|
||
const exSub = ex.length ? ex.map((e) => e.site).join(" · ") : "—"
|
||
return [
|
||
{
|
||
label: "Home роутеров",
|
||
value: homeCount,
|
||
sub: `${wanCount} WAN-аплинков`,
|
||
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
|
||
},
|
||
{
|
||
label: "JumpHost",
|
||
value: jh.length,
|
||
sub: jhSub,
|
||
icon: <ServerIcon className="size-4 text-violet-400" />,
|
||
},
|
||
{
|
||
label: "Exit Node",
|
||
value: ex.length,
|
||
sub: exSub,
|
||
icon: <NetworkIcon className="size-4 text-emerald-500" />,
|
||
},
|
||
{
|
||
label: "Переключений",
|
||
value: totalSwitches,
|
||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||
icon: (
|
||
<ZapIcon
|
||
className={cn(
|
||
"size-4",
|
||
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
|
||
)}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
||
|
||
function applyAll() {
|
||
data?.homes.forEach(h =>
|
||
h.commRecs
|
||
.filter(r => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`))
|
||
.forEach(r => applyRec(r.community, h.home.id))
|
||
)
|
||
}
|
||
|
||
async function applyOspfOptimization() {
|
||
if (!ospfServerId) return
|
||
setOspfApplying(true)
|
||
setOspfApplyError("")
|
||
setOspfApplyResult(null)
|
||
try {
|
||
const res = await apiFetch<{ serverName: string; optimizedCount: number }>(
|
||
`/api/servers/${ospfServerId}/ospf/optimize`,
|
||
{
|
||
method: "POST",
|
||
body: JSON.stringify({ pingWeight: settings.pingWeight }),
|
||
},
|
||
)
|
||
setOspfApplyResult({
|
||
serverName: res.serverName,
|
||
optimizedCount: res.optimizedCount,
|
||
})
|
||
await load()
|
||
setOspfPreview((prev) => (prev ? { ...prev, changedCount: 0, changes: [] } : prev))
|
||
} catch (e) {
|
||
setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации")
|
||
} finally {
|
||
setOspfApplying(false)
|
||
}
|
||
}
|
||
|
||
const set = <K extends keyof OptimizerSettings>(k: K, v: OptimizerSettings[K]) =>
|
||
setSettings(prev => ({ ...prev, [k]: v }))
|
||
|
||
useEffect(() => {
|
||
if (!useLiveData || !ospfServerId) return
|
||
let cancelled = false
|
||
void apiFetch<{
|
||
interfaces: Array<{ areaId: string; interface: string }>
|
||
instances: Array<unknown>
|
||
neighbors: Array<unknown>
|
||
bfdSessions: Array<unknown>
|
||
}>(`/api/servers/${ospfServerId}/ospf`)
|
||
.then((data) => {
|
||
if (cancelled) return
|
||
const selected = liveServers.find((s) => String(s.id) === ospfServerId)
|
||
const visibleInterfaces = data.interfaces.filter((i) => !/^\(ref\s+\*.+\)$/.test(i.interface.trim()))
|
||
setOspfMeta({
|
||
interfaces: visibleInterfaces.length,
|
||
areas: new Set(visibleInterfaces.map((i) => i.areaId)).size,
|
||
serverName: selected?.name || selected?.host || ospfServerId,
|
||
})
|
||
})
|
||
.catch(() => {
|
||
if (cancelled) return
|
||
setOspfMeta(null)
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [apiFetch, liveServers, ospfServerId, useLiveData])
|
||
|
||
useEffect(() => {
|
||
if (!useLiveData || !ospfServerId) return
|
||
let cancelled = false
|
||
void apiFetch<{
|
||
changedCount: number
|
||
interfacesTotal: number
|
||
interfaces: Array<{
|
||
interface: string
|
||
currentCost: number
|
||
optimalCost: number
|
||
score: number
|
||
pingMs: number
|
||
dlMbps: number
|
||
ulMbps: number
|
||
}>
|
||
changes: Array<{
|
||
interface: string
|
||
currentCost: number
|
||
optimalCost: number
|
||
score: number
|
||
pingMs: number
|
||
dlMbps: number
|
||
ulMbps: number
|
||
}>
|
||
}>(
|
||
`/api/servers/${ospfServerId}/ospf/optimize/preview`,
|
||
{ method: "POST", body: JSON.stringify({ pingWeight: settings.pingWeight }) },
|
||
)
|
||
.then((data) => {
|
||
if (cancelled) return
|
||
setOspfPreviewError("")
|
||
setOspfPreview(data)
|
||
})
|
||
.catch((e) => {
|
||
if (cancelled) return
|
||
setOspfPreview(null)
|
||
setOspfPreviewError(e instanceof Error ? e.message : "Ошибка preview OSPF")
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [apiFetch, ospfServerId, settings.pingWeight, useLiveData])
|
||
|
||
const ospfPreviewLoading = useLiveData && Boolean(ospfServerId) && !ospfPreview && !ospfPreviewError
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Инструменты" }, { label: "Оптимизатор маршрутов" }]}
|
||
actions={
|
||
<>
|
||
{totalSwitches > 0 && (
|
||
<Button size="sm" onClick={applyAll}>
|
||
<ZapIcon className="size-4" />Применить все ({totalSwitches})
|
||
</Button>
|
||
)}
|
||
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
|
||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||
{loading ? "Расчёт…" : "Обновить"}
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<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>
|
||
<span className="text-border">·</span>
|
||
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
|
||
<Chip>{useLiveData ? "Живые данные · API" : "Демо · mock"}</Chip>
|
||
{useLiveData && backendStatus === false && (
|
||
<span className="text-amber-600 dark:text-amber-400">
|
||
бекенд не отвечает на /health — проверьте URL в настройках
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Stats chips */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
{statsChips.map((s) => (
|
||
<Card key={s.label}>
|
||
<CardContent className="px-4 py-3 flex items-start justify-between">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||
<p className="text-xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.sub}</p>
|
||
</div>
|
||
<div className="mt-0.5">{s.icon}</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{error && (
|
||
<div 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 className="size-4 shrink-0" />{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading && !data && (
|
||
<div className="text-muted-foreground text-sm py-8 text-center animate-pulse">
|
||
Расчёт оптимальных маршрутов…
|
||
</div>
|
||
)}
|
||
|
||
{/* Settings */}
|
||
<Card className="gap-0 py-0">
|
||
<div
|
||
className={cn("flex items-center gap-3 px-4 py-3 cursor-pointer select-none", showSettings && "border-b")}
|
||
onClick={() => setShowSettings(v => !v)}
|
||
>
|
||
<SettingsIcon 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 className="size-4 text-muted-foreground" /> : <ChevronDownIcon className="size-4 text-muted-foreground" />}
|
||
</div>
|
||
|
||
{showSettings && (
|
||
<CardContent 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 value={settings.switchThreshold} onChange={v => set("switchThreshold", v)} min={0} max={50} />
|
||
</SettingRow>
|
||
<SettingRow label="Гистерезис" unit="%">
|
||
<NInput value={settings.hysteresisThreshold} onChange={v => set("hysteresisThreshold", v)} min={0} max={50} />
|
||
</SettingRow>
|
||
</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 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-sky-500 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 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-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm">Применять автоматически</span>
|
||
<Toggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
||
</div>
|
||
{settings.autoApply && (
|
||
<>
|
||
<SettingRow label="Интервал (мин)">
|
||
<NInput value={settings.autoApplyIntervalMin} onChange={v => set("autoApplyIntervalMin", v)} min={5} max={1440} />
|
||
</SettingRow>
|
||
<div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-600 dark:text-amber-400">
|
||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||
Изменения каждые {settings.autoApplyIntervalMin} мин
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end gap-2 mt-5 pt-4 border-t">
|
||
<Button variant="outline" size="sm" onClick={() => setSettings({ ...DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS })}>Сбросить</Button>
|
||
<Button size="sm" onClick={() => void load(settings)}>
|
||
<CheckCircleIcon 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>
|
||
</CardContent>
|
||
)}
|
||
</Card>
|
||
|
||
{/* OSPF optimization from Route Optimizer */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4 flex flex-col gap-2.5">
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<NetworkIcon className="size-4 text-sky-400 shrink-0" />
|
||
<span className="font-semibold">OSPF</span>
|
||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
||
Route AI weight: ping {settings.pingWeight}%
|
||
</span>
|
||
</div>
|
||
|
||
{!useLiveData && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Доступно только в режиме живых данных.
|
||
</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
|
||
className="text-xs bg-background text-foreground border border-input rounded-md px-2 py-1 h-7 min-w-64 focus:outline-none focus:ring-1 focus:ring-ring"
|
||
value={ospfServerId}
|
||
onChange={(e) => setOspfServerId(e.target.value)}
|
||
>
|
||
{liveServers.length === 0 && <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">
|
||
{ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
className="ml-auto h-7 text-xs"
|
||
onClick={() => void applyOspfOptimization()}
|
||
disabled={!ospfServerId || ospfApplying}
|
||
>
|
||
<ZapIcon 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 ?? 0}
|
||
</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 items-center justify-between">
|
||
<span>Preview изменений OSPF cost (до применения)</span>
|
||
<span>
|
||
{ospfPreviewLoading
|
||
? "расчёт…"
|
||
: ospfPreview
|
||
? `${ospfPreview.changedCount} из ${ospfPreview.interfacesTotal} изменятся`
|
||
: "нет данных"}
|
||
</span>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b bg-muted/30">
|
||
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
|
||
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border/60">
|
||
{ospfPreviewError && (
|
||
<tr>
|
||
<td colSpan={5} className="px-3 py-2 text-destructive">
|
||
Ошибка preview: {ospfPreviewError}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
|
||
<tr>
|
||
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
|
||
Интерфейсы OSPF не найдены для выбранного сервера.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{(ospfPreview?.interfaces ?? []).map((row) => (
|
||
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
|
||
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
|
||
<td className="px-3 py-1.5 font-mono">
|
||
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
|
||
{" → "}
|
||
<span className={row.currentCost === row.optimalCost
|
||
? "text-emerald-600 dark:text-emerald-400"
|
||
: "text-amber-600 dark:text-amber-400"}
|
||
>
|
||
{row.optimalCost}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-1.5 font-mono">{row.score}</td>
|
||
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
|
||
<td className="px-3 py-1.5 font-mono">↓{row.dlMbps} / ↑{row.ulMbps}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{ospfApplyResult && (
|
||
<div className="text-xs rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-600 dark:text-emerald-400">
|
||
Применено на {ospfApplyResult.serverName}: изменено интерфейсов — {ospfApplyResult.optimizedCount}.
|
||
</div>
|
||
)}
|
||
|
||
{ospfApplyError && (
|
||
<div className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||
Ошибка OSPF-оптимизации: {ospfApplyError}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||
|
||
{/* ECMP Card */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<GitMergeIcon className="size-4 text-violet-400" />
|
||
<span className="font-semibold text-sm">ECMP</span>
|
||
<span className="text-[10px] text-muted-foreground ml-1">Equal-Cost Multi-Path</span>
|
||
<div className="ml-auto">
|
||
<button type="button" role="switch" 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-violet-500" : "bg-input")}>
|
||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||
ecmpEnabled ? "translate-x-4" : "translate-x-0")} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn("flex flex-col gap-3 transition-opacity", !ecmpEnabled && "opacity-40 pointer-events-none")}>
|
||
<div className="flex flex-col gap-1.5">
|
||
<span className="text-xs text-muted-foreground">Макс. путей</span>
|
||
<div className="flex items-center gap-2">
|
||
<input type="range" min={1} max={64} step={1} value={ecmpMaxPaths}
|
||
onChange={e => setEcmpMaxPaths(Number(e.target.value))}
|
||
className="flex-1 accent-violet-500 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 key={v} onClick={() => setEcmpAlgo(v)}
|
||
className={cn("flex-1 transition-colors cursor-pointer",
|
||
ecmpAlgo === v ? "bg-violet-500 text-white" : "text-muted-foreground hover:bg-muted")}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-2 rounded-lg border border-violet-500/20 bg-violet-500/5 px-2.5 py-2 text-[11px] text-violet-600 dark:text-violet-400">
|
||
<InfoIcon className="size-3 shrink-0 mt-0.5" />
|
||
<span>RouterOS 7.x: <code className="font-mono">/routing/rule add ecmp=yes</code></span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* RPF Card */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<ShieldIcon className="size-4 text-amber-400" />
|
||
<span className="font-semibold text-sm">RPF</span>
|
||
<span className="text-[10px] text-muted-foreground ml-1">Reverse Path Forwarding</span>
|
||
</div>
|
||
|
||
<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-amber-500" },
|
||
{ v: "strict", l: "Strict", d: "Пакет пришёл по лучшему маршруту", cls: "text-emerald-500" },
|
||
] as const).map(({ v, l, d, cls }) => (
|
||
<button key={v} type="button" 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-amber-500/30 bg-amber-500/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-amber-500 bg-amber-500" : "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-amber-500/20 bg-amber-500/5 px-2.5 py-2 text-[11px] text-amber-600 dark:text-amber-400">
|
||
<InfoIcon className="size-3 shrink-0 mt-0.5" />
|
||
<span><code className="font-mono">/ip settings set rp-filter={rpfMode}</code></span>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* VRF Card */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<LayersIcon className="size-4 text-sky-400" />
|
||
<span className="font-semibold text-sm">VRF</span>
|
||
<span className="text-[10px] text-muted-foreground ml-1">Virtual Routing</span>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-1.5">
|
||
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
|
||
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto pr-0.5">
|
||
{VRF_NAMES.map(vrf => {
|
||
const isSel = selectedVrf === vrf
|
||
const isMain = vrf === "main"
|
||
return (
|
||
<button key={vrf} type="button" 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-sky-500/40 bg-sky-500/8 text-sky-400"
|
||
: "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-sky-500" : "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-sky-500/20 bg-sky-500/5 px-2.5 py-2 text-[11px] text-sky-600 dark:text-sky-400">
|
||
<InfoIcon className="size-3 shrink-0 mt-0.5" />
|
||
<span>Оптимизатор работает в VRF <code className="font-mono">{selectedVrf}</code></span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
|
||
{/* Per-home-router cards */}
|
||
{data?.homes.map((entry) => (
|
||
<HomeRouterCard
|
||
key={entry.home.id}
|
||
entry={entry}
|
||
jumpHosts={jumpHostsForCards}
|
||
settings={settings}
|
||
pinned={pinned}
|
||
applied={applied}
|
||
applying={applying}
|
||
onPin={togglePin}
|
||
onApply={applyRec}
|
||
/>
|
||
))}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|