chore: synchronize pending app/backend updates and repository hygiene
Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,11 +22,13 @@ import {
|
||||
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,
|
||||
@@ -93,13 +95,6 @@ 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"
|
||||
}
|
||||
@@ -161,7 +156,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
|
||||
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),
|
||||
score: calcRouteScore(ping, dl, ul, pw),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -177,7 +172,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
|
||||
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)
|
||||
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,
|
||||
@@ -521,7 +516,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{recs.map(r => {
|
||||
{recs.map((r, idx) => {
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
const isApplied = applied.has(pinKey)
|
||||
@@ -529,7 +524,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
|
||||
const canApply = r.shouldSwitch && !isPinned && !isApplied
|
||||
|
||||
return (
|
||||
<tr key={r.community} className={cn(
|
||||
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
|
||||
"hover:bg-muted/30 transition-colors",
|
||||
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
|
||||
isApplied && "bg-emerald-500/5",
|
||||
@@ -782,6 +777,7 @@ export default function RouteOptimizerPage() {
|
||||
|
||||
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())
|
||||
@@ -792,6 +788,34 @@ export default function RouteOptimizerPage() {
|
||||
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
|
||||
@@ -803,9 +827,13 @@ export default function RouteOptimizerPage() {
|
||||
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)
|
||||
@@ -819,13 +847,21 @@ export default function RouteOptimizerPage() {
|
||||
} 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))
|
||||
setData(buildLiveOptimizerData(rows, rulesets, s, speedProbes))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||
setData(null)
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
setLiveServers([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -929,9 +965,104 @@ export default function RouteOptimizerPage() {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1081,6 +1212,136 @@ export default function RouteOptimizerPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* OSPF optimization from Route Optimizer */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-4 flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<NetworkIcon className="size-4 text-sky-400 shrink-0" />
|
||||
<span className="font-semibold">OSPF</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
Route AI weight: ping {settings.pingWeight}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!useLiveData && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Доступно только в режиме живых данных.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{useLiveData && (
|
||||
<>
|
||||
<div className="rounded-lg border bg-muted/15 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-background/80 flex-wrap">
|
||||
<select
|
||||
className="text-xs bg-background text-foreground border border-input rounded-md px-2 py-1 h-7 min-w-64 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={ospfServerId}
|
||||
onChange={(e) => setOspfServerId(e.target.value)}
|
||||
>
|
||||
{liveServers.length === 0 && <option value="">Нет доступных серверов</option>}
|
||||
{liveServers.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>
|
||||
{s.name || s.host} ({s.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto h-7 text-xs"
|
||||
onClick={() => void applyOspfOptimization()}
|
||||
disabled={!ospfServerId || ospfApplying}
|
||||
>
|
||||
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
|
||||
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 text-xs grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
<div className="text-muted-foreground">Router</div>
|
||||
<div className="md:col-span-2 font-mono truncate">{ospfMeta?.serverName ?? "—"}</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
apply: {ospfApplyResult?.optimizedCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background/70 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b text-[11px] text-muted-foreground flex items-center justify-between">
|
||||
<span>Preview изменений OSPF cost (до применения)</span>
|
||||
<span>
|
||||
{ospfPreviewLoading
|
||||
? "расчёт…"
|
||||
: ospfPreview
|
||||
? `${ospfPreview.changedCount} из ${ospfPreview.interfacesTotal} изменятся`
|
||||
: "нет данных"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30">
|
||||
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
|
||||
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{ospfPreviewError && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-2 text-destructive">
|
||||
Ошибка preview: {ospfPreviewError}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
|
||||
Интерфейсы OSPF не найдены для выбранного сервера.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(ospfPreview?.interfaces ?? []).map((row) => (
|
||||
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
|
||||
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
|
||||
<td className="px-3 py-1.5 font-mono">
|
||||
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
|
||||
{" → "}
|
||||
<span className={row.currentCost === row.optimalCost
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400"}
|
||||
>
|
||||
{row.optimalCost}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono">{row.score}</td>
|
||||
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
|
||||
<td className="px-3 py-1.5 font-mono">↓{row.dlMbps} / ↑{row.ulMbps}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ospfApplyResult && (
|
||||
<div className="text-xs rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-600 dark:text-emerald-400">
|
||||
Применено на {ospfApplyResult.serverName}: изменено интерфейсов — {ospfApplyResult.optimizedCount}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ospfApplyError && (
|
||||
<div className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
Ошибка OSPF-оптимизации: {ospfApplyError}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user