Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell. Co-authored-by: Cursor <cursoragent@cursor.com>
1113 lines
48 KiB
TypeScript
1113 lines
48 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||
import Link from "next/link"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { DataPageCard } from "@/components/data-page-card"
|
||
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
|
||
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
|
||
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
|
||
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
||
import { FormToggle } from "@/components/form-kit"
|
||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||
import { IconTile } from "@/components/reui/icon-tile"
|
||
import { OpsPanel } from "@/components/ops-panel"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Flag } from "@/components/flag"
|
||
import { cn } from "@/lib/utils"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import { servers } from "@/lib/data"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import {
|
||
type HomeRouter,
|
||
type JumpHost,
|
||
type ExitNode,
|
||
type WanJhLeg,
|
||
type JhExLeg,
|
||
type FullRoute,
|
||
type CommRec,
|
||
type HomeEntry,
|
||
type OptimizerData,
|
||
type OptimizerSettings,
|
||
type OptimizerApiServer,
|
||
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,
|
||
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 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>
|
||
)
|
||
}
|
||
|
||
// ─── 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 (
|
||
<Frame dense className="w-full overflow-hidden">
|
||
<FramePanel className="p-0 overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
|
||
<MonitorIcon 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" && (
|
||
<RouteOptimizerWanMatrixDataGrid home={home} legs={wanJhLegs} jumpHosts={jumpHosts} />
|
||
)}
|
||
{tab === "full-routes" && (
|
||
<RouteOptimizerFullRoutesDataGrid routes={fullRoutes} bestId={bestRoute?.id} />
|
||
)}
|
||
{tab === "bgp-community" && (
|
||
<RouteOptimizerCommRecsDataGrid
|
||
recs={commRecs}
|
||
homeId={home.id}
|
||
pinned={pinned} applied={applied} applying={applying}
|
||
onPin={onPin} onApply={onApply}
|
||
/>
|
||
)}
|
||
</FramePanel>
|
||
</Frame>
|
||
)
|
||
}
|
||
|
||
// ─── Topology legend ──────────────────────────────────────────────────────────
|
||
|
||
function TopologyBar() {
|
||
return (
|
||
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
|
||
<div className="flex items-center gap-1.5">
|
||
<MonitorIcon 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) => (
|
||
<Frame key={s.label} className="h-full">
|
||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||
{s.icon}
|
||
</IconTile>
|
||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p>
|
||
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
|
||
</div>
|
||
</FramePanel>
|
||
</Frame>
|
||
))}
|
||
</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 */}
|
||
<Frame dense className="w-full">
|
||
<FramePanel className="p-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 && (
|
||
<div className="py-4 px-5">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||
<div className="flex flex-col gap-3">
|
||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
|
||
<SettingRow label="Мин. выигрыш" unit="%">
|
||
<NInput 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>
|
||
<FormToggle 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>
|
||
</div>
|
||
)}
|
||
</FramePanel>
|
||
</Frame>
|
||
|
||
{/* OSPF optimization from Route Optimizer */}
|
||
<OpsPanel
|
||
title="OSPF"
|
||
description={`Route AI weight: ping ${settings.pingWeight}%`}
|
||
contentClassName="px-5 py-4 flex flex-col gap-2.5"
|
||
>
|
||
|
||
{!useLiveData && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Доступно только в режиме живых данных.
|
||
</p>
|
||
)}
|
||
|
||
{useLiveData && (
|
||
<>
|
||
<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>
|
||
<RouteOptimizerOspfPreviewDataGrid
|
||
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
|
||
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
|
||
...row,
|
||
}))}
|
||
error={ospfPreviewError || null}
|
||
loading={ospfPreviewLoading}
|
||
/>
|
||
</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>
|
||
)}
|
||
</>
|
||
)}
|
||
</OpsPanel>
|
||
|
||
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||
|
||
{/* ECMP Card */}
|
||
<OpsPanel
|
||
title="ECMP"
|
||
description="Equal-Cost Multi-Path"
|
||
headerRight={
|
||
<button type="button" role="switch" aria-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>
|
||
}
|
||
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||
>
|
||
|
||
<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>
|
||
</OpsPanel>
|
||
|
||
{/* RPF Card */}
|
||
<OpsPanel title="RPF" description="Reverse Path Forwarding" contentClassName="px-5 py-4 flex flex-col gap-4">
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-xs text-muted-foreground">Режим проверки источника</span>
|
||
<div className="flex flex-col gap-1.5">
|
||
{([
|
||
{ v: "disabled", l: "Отключён", d: "Проверка не выполняется", cls: "text-muted-foreground" },
|
||
{ v: "loose", l: "Loose", d: "Маршрут к источнику существует", cls: "text-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>
|
||
)}
|
||
</OpsPanel>
|
||
|
||
{/* VRF Card */}
|
||
<OpsPanel title="VRF" description="Virtual Routing" contentClassName="px-5 py-4 flex flex-col gap-4">
|
||
|
||
<div className="flex flex-col gap-1.5">
|
||
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
|
||
<div className="flex flex-col gap-1.5 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>
|
||
</OpsPanel>
|
||
|
||
</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>
|
||
)
|
||
}
|