This commit is contained in:
Denozordec
2026-05-03 11:16:07 +07:00
parent ce00c4c671
commit bdb9b72fac
66 changed files with 9553 additions and 1547 deletions
+222 -171
View File
@@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useState, useMemo } from "react"
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"
@@ -8,6 +9,25 @@ 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,
@@ -16,111 +36,29 @@ import {
InfoIcon,
} from "lucide-react"
// ─── Types ────────────────────────────────────────────────────────────────────
interface WanUplink {
id: string
name: string // "WAN1-RT"
isp: string // "Rostelecom"
iface: string // "ether1"
ip: string // external IP
maxDl: number // Mbps
maxUl: number // Mbps
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>
}
}
interface HomeRouter {
id: string
label: string // "home-msk-01"
site: string // "MSK"
country: string
model: string
ip: string // LAN management IP
wans: WanUplink[]
}
interface JumpHost {
id: string
label: string
site: string
country: string
ip: string
}
interface ExitNode {
id: string
label: string
site: string
country: string
ip: string
}
// WAN→JH measurement (per uplink)
interface WanJhLeg {
wanId: string
jhId: string
pingMs: number
dlMbps: number
ulMbps: number
score: number
loss: number
}
// JH→Exit measurement
interface JhExLeg {
jhId: string
exitId: string
pingMs: number
dlMbps: number
ulMbps: number
}
// Full route: HomeRouter + WAN → JH → Exit
interface FullRoute {
id: string
homeId: string
wan: WanUplink
jh: JumpHost
exit: ExitNode
hw: WanJhLeg
je: JhExLeg
score: number
confidence: "HIGH" | "MEDIUM" | "LOW"
probabilityOptimal: number
}
// BGP community recommendation
interface CommRec {
community: string
communityName: string
current: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
recommended: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
shouldSwitch: boolean
pinnedBySettings: boolean
}
interface HomeEntry {
home: HomeRouter
wanJhLegs: WanJhLeg[] // all (WAN × JH) measurements
fullRoutes: FullRoute[] // sorted by probabilityOptimal
bestRoute: FullRoute | null
commRecs: CommRec[]
}
interface OptimizerData {
updatedAt: string
homes: HomeEntry[]
}
interface OptimizerSettings {
switchThreshold: number
hysteresisThreshold: number
pingWeight: number
probeIntervalMin: number
autoApply: boolean
autoApplyIntervalMin: number
}
// ─── Topology derived from lib/data servers ───────────────────────────────────
// ─── Topology derived from lib/data servers (mock) ───────────────────────────
const HOME_ROUTERS: HomeRouter[] = servers
.filter(s => s.type === "home-router" && s.enabled)
@@ -361,13 +299,14 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
// Rows = WANs, Columns = JHs, cells show ping / bw / score
function WanMatrix({ home, legs, pw: _pw }: {
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
home: HomeRouter
legs: WanJhLeg[]
jumpHosts: JumpHost[]
pw: number
}) {
// find best leg overall
const bestScore = Math.max(...legs.map(l => l.score))
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
return (
<div className="overflow-x-auto">
@@ -377,7 +316,7 @@ function WanMatrix({ home, legs, pw: _pw }: {
<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 => (
{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">
@@ -412,7 +351,7 @@ function WanMatrix({ home, legs, pw: _pw }: {
<p className="font-mono text-[10px] text-muted-foreground">{wan.maxUl} Мбит</p>
</td>
{/* Per-JH cells */}
{JUMPHOSTS.map(jh => {
{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
@@ -682,8 +621,9 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onApply }: {
function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying, onPin, onApply }: {
entry: HomeEntry
jumpHosts: JumpHost[]
settings: OptimizerSettings
pinned: Set<string>
applied: Set<string>
@@ -741,7 +681,7 @@ function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onA
{/* 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: "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 => (
@@ -759,7 +699,7 @@ function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onA
{/* Tab content */}
{tab === "wan-matrix" && (
<WanMatrix home={home} legs={wanJhLegs} pw={settings.pingWeight} />
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
)}
{tab === "full-routes" && (
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
@@ -807,49 +747,111 @@ function TopologyBar() {
// ════════════════════════════════════════════════════════════════════════════
const DEFAULT_SETTINGS: OptimizerSettings = {
switchThreshold: 15,
hysteresisThreshold: 10,
pingWeight: 60,
probeIntervalMin: 15,
autoApply: false,
autoApplyIntervalMin: 60,
}
export default function RouteOptimizerPage() {
const [loading, setLoading] = useState(true)
const [data, setData] = useState<OptimizerData | null>(null)
const [error, setError] = useState("")
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_SETTINGS)
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())
// ECMP / RPF / VRF
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 { mode, backendUrl, backendStatus } = useDataSource()
/** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */
const useLiveData = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const load = useCallback((s: OptimizerSettings = settings) => {
setLoading(true); setError("")
setTimeout(() => {
try { setData(buildMockData(s)) }
catch { setError("Ошибка расчёта маршрутов") }
finally { setLoading(false) }
}, 600)
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])
// Initial load + polling every 30 s — load() calls setState internally (standard data-fetching pattern)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load()
const id = setInterval(() => load(), 30_000)
return () => clearInterval(id)
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 })
}
@@ -863,10 +865,61 @@ export default function RouteOptimizerPage() {
}, 1200)
}
const totalSwitches = useMemo(() =>
data?.homes.flatMap(h => h.commRecs)
.filter(r => r.shouldSwitch && !pinned.has(r.community)).length ?? 0
, [data, pinned])
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 =>
@@ -890,7 +943,7 @@ export default function RouteOptimizerPage() {
<ZapIcon className="size-4" />Применить все ({totalSwitches})
</Button>
)}
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
{loading ? "Расчёт…" : "Обновить"}
</Button>
@@ -907,26 +960,18 @@ export default function RouteOptimizerPage() {
<span className="text-border">·</span>
<span>Обновлено: {data?.updatedAt ?? "—"}</span>
<span className="text-border">·</span>
<span>Авто 30 с</span>
<Chip>симуляция</Chip>
<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">
{[
{ label: "Home роутеров", value: HOME_ROUTERS.length,
sub: `${HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)} WAN-аплинков`,
icon: <MonitorIcon className="size-4 text-muted-foreground" /> },
{ label: "JumpHost", value: JUMPHOSTS.length,
sub: JUMPHOSTS.map(j => j.site).join(" · "),
icon: <ServerIcon className="size-4 text-violet-400" /> },
{ label: "Exit Node", value: EXIT_NODES.length,
sub: EXIT_NODES.map(e => e.site).join(" · "),
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")} /> },
].map(s => (
{statsChips.map((s) => (
<Card key={s.label}>
<CardContent className="px-4 py-3 flex items-start justify-between">
<div>
@@ -1022,11 +1067,16 @@ export default function RouteOptimizerPage() {
</div>
</div>
<div className="flex justify-end gap-2 mt-5 pt-4 border-t">
<Button variant="outline" size="sm" onClick={() => setSettings(DEFAULT_SETTINGS)}>Сбросить</Button>
<Button size="sm" onClick={() => load(settings)}>
<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>
@@ -1173,10 +1223,11 @@ export default function RouteOptimizerPage() {
</div>
{/* Per-home-router cards */}
{data?.homes.map(entry => (
{data?.homes.map((entry) => (
<HomeRouterCard
key={entry.home.id}
entry={entry}
jumpHosts={jumpHostsForCards}
settings={settings}
pinned={pinned}
applied={applied}