1245 lines
56 KiB
TypeScript
1245 lines
56 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 { 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,
|
||
} 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> {
|
||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||
...init,
|
||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||
})
|
||
if (!res.ok) {
|
||
let message = res.statusText
|
||
try {
|
||
const err = await res.json() as { error?: string }
|
||
message = err.error ?? message
|
||
} catch {
|
||
const text = await res.text().catch(() => "")
|
||
if (text) message = text
|
||
}
|
||
throw new Error(message)
|
||
}
|
||
return res.json() as Promise<T>
|
||
}
|
||
}
|
||
|
||
// ─── 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 calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) {
|
||
const pingScore = Math.max(0, 100 - pingMs * 0.6)
|
||
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
|
||
const w = pw / 100
|
||
return Math.round(w * pingScore + (1 - w) * speedScore)
|
||
}
|
||
|
||
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: calcScore(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 = calcScore(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 => {
|
||
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={r.community} 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 [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 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([])
|
||
return
|
||
}
|
||
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
|
||
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
|
||
}
|
||
|
||
setData(buildLiveOptimizerData(rows, rulesets, s))
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||
setData(null)
|
||
setLiveJumpHosts([])
|
||
setLiveExitNodes([])
|
||
} 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))
|
||
)
|
||
}
|
||
|
||
const set = <K extends keyof OptimizerSettings>(k: K, v: OptimizerSettings[K]) =>
|
||
setSettings(prev => ({ ...prev, [k]: v }))
|
||
|
||
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>
|
||
|
||
{/* ─── 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>
|
||
)
|
||
}
|