fix(ui): защищать оптимизацию OSPF и подтверждение результата

This commit is contained in:
Denozordec committed 2026-09-30 17:46:56 +07:00
1 parent 4254904c3a
commit baa43b7ad4
8 files changed
+193 -42

No files matched your search

+40 -39
View File
@@ -1,7 +1,7 @@
"use client"
import { useMemo, useState, useEffect } from "react"
import { ospfCatalogSchema, ospfServersSchema, type OspfCatalog } from "@/lib/ospf-response"
import { useMemo, useState, useEffect, useRef } from "react"
import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
@@ -12,6 +12,7 @@ import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { can } from "@/lib/auth"
import { toast } from "sonner"
import {
RefreshCwIcon, WandSparklesIcon, GripVerticalIcon,
@@ -116,10 +117,6 @@ interface BackendInterface {
helloInterval: number; deadInterval: number; useBfd: boolean
}
interface BackendInstance {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
name: string; routerId: string; version: number; disabled: boolean; inactive: boolean; redistribute: string
}
interface BackendBfdSession {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
@@ -138,23 +135,6 @@ function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
interface BackendOspfOptimizeResponse {
serverId: number
serverName: string
pingWeight: number
optimizedCount: number
applied: Array<{ interface: string; from: number; to: number }>
interfaces: Array<{
id: string
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
}
// ─── backend → frontend mappers ───────────────────────────────────────────────
@@ -679,6 +659,7 @@ function InterfacesTab({
onLiveDataRefresh,
dataReady,
dataIncomplete,
onOperationBusy,
}: {
items: OspfItem[]
isLive: boolean
@@ -687,12 +668,17 @@ function InterfacesTab({
onLiveDataRefresh: () => void
dataReady: boolean
dataIncomplete: boolean
onOperationBusy: (busy: boolean) => void
}) {
const [items, setItems] = useState<OspfItem[]>(initialItems)
const [dragging, setDragging] = useState<string | null>(null)
const [dragOver, setDragOver] = useState<string | null>(null)
const [optimizing, setOptimizing] = useState(false)
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
const [operationError, setOperationError] = useState<string | null>(null)
const operationLock = useRef(false)
const mounted = useRef(true)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
// Sync with live data when it changes
useEffect(() => {
@@ -738,14 +724,17 @@ function InterfacesTab({
const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID
async function handleLiveOptimize() {
if (!canOptimizeLive) {
if (!mounted.current || operationLock.current || !canOptimizeLive || !can("mm:servers:write")) {
toast.info("Выберите конкретный сервер для оптимизации OSPF")
return
}
const ra = readStoredRouteOptimizerSettings()
operationLock.current = true
onOperationBusy(true)
setOperationError(null)
setOptimizing(true)
try {
const data = await requestJson<BackendOspfOptimizeResponse>(
const response = await requestJson<unknown>(
backendUrl,
`/api/servers/${filterServerId}/ospf/optimize`,
{
@@ -753,17 +742,23 @@ function InterfacesTab({
body: JSON.stringify({ pingWeight: ra.pingWeight }),
},
)
if (!mounted.current) return
const data = ospfOptimizeSchema.parse(response)
if (String(data.serverId) !== filterServerId) throw new Error("API вернул другой контекст OSPF")
const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
})
setLiveOptimalCost(byKey)
toast.success(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`)
onLiveDataRefresh()
} catch (err) {
toast.error(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
if (!mounted.current) return
setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`)
onLiveDataRefresh()
} finally {
setOptimizing(false)
operationLock.current = false
if (mounted.current) { setOptimizing(false); onOperationBusy(false) }
}
}
@@ -790,8 +785,9 @@ function InterfacesTab({
return (
<div className="flex flex-col gap-4">
{operationError && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Оптимизация OSPF не подтверждена</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => setItems(initialItems)}>
<Button variant="outline" size="sm" disabled={optimizing} onClick={() => setItems(initialItems)}>
<RefreshCwIcon className="size-4" />{isLive ? "Последний снимок" : "Сбросить предпросмотр"}
</Button>
{needsOptimize && (
@@ -799,8 +795,8 @@ function InterfacesTab({
<WandSparklesIcon className="size-4" />Оптимизировать
</Button>
)}
{canOptimizeLive && (
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing}>
{isLive && filterServerId !== ALL_SERVERS_ID && (
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing || !dataReady || !can("mm:servers:write")}>
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
</Button>
@@ -1155,6 +1151,9 @@ function OspfPageContent() {
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0)
const operationBusy = useRef(false)
const [writeBusy, setWriteBusy] = useState(false)
function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) }
// Reset server filter when switching live ↔ mock
useEffect(() => {
@@ -1193,7 +1192,7 @@ function OspfPageContent() {
const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData])
const incomplete = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId))))
const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !liveData.failures.length)
const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write"))
// Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
@@ -1280,6 +1279,7 @@ function OspfPageContent() {
const host = s.label.replace(/^mt-/, "")
return {
id: s.id,
selectable: !writeBusy,
name: host,
host,
site: s.site,
@@ -1289,7 +1289,7 @@ function OspfPageContent() {
: undefined,
}
})
), [ospfServers, serverCounts, isLive, unknownIds])
), [ospfServers, serverCounts, isLive, unknownIds, writeBusy])
// Graph always shows full topology (highlight is handled by node click inside tab)
// KPIs reflect the current filter
@@ -1302,7 +1302,7 @@ function OspfPageContent() {
preserveSelection
items={ospfRailItems}
selectedId={filterServerId}
onSelect={setFilterServerId}
onSelect={id => { if (!operationBusy.current) setFilterServerId(id) }}
showAll
allCount={ospfServers.length}
loading={isLive && loading && ospfServers.length === 0}
@@ -1312,7 +1312,7 @@ function OspfPageContent() {
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
<Button variant="outline" size="sm" disabled={!isLive || loading || writeBusy} onClick={refresh}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
@@ -1324,7 +1324,7 @@ function OspfPageContent() {
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} aria-pressed={activeTab === t.id} onClick={() => setActiveTab(t.id)}
<button key={t.id} aria-pressed={activeTab === t.id} disabled={writeBusy} onClick={() => { if (!operationBusy.current) setActiveTab(t.id) }}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
@@ -1347,7 +1347,7 @@ function OspfPageContent() {
</div>
)}
{isLive && fetchedAt && <p role="group" aria-label="Свежесть OSPF" className="text-xs text-muted-foreground">Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>. Время получения браузером, не измерения RouterOS.</p>}
{isLive && (liveError || Boolean(liveData?.failures.length)) && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>{liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}</AlertTitle><AlertDescription>{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку OSPF">Повторить</Button></AlertDescription></Alert>}
{isLive && (liveError || Boolean(liveData?.failures.length)) && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>{liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}</AlertTitle><AlertDescription>{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}<Button variant="outline" size="sm" disabled={loading || writeBusy} onClick={refresh} aria-label="Повторить загрузку OSPF">Повторить</Button></AlertDescription></Alert>}
{incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) && <p role="status">Данные выбранного раздела OSPF пока неизвестны.</p>}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
@@ -1384,14 +1384,15 @@ function OspfPageContent() {
/>
{activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && (
<InterfacesTab
<InterfacesTab key={filterServerId}
items={displayItems}
isLive={isLive}
filterServerId={filterServerId}
backendUrl={backendUrl}
onLiveDataRefresh={() => setFetchTick(t => t + 1)}
onLiveDataRefresh={() => { setLoading(true); setFetchTick(t => t + 1) }}
dataReady={dataReady}
dataIncomplete={incomplete}
onOperationBusy={busy => { operationBusy.current = busy; setWriteBusy(busy) }}
/>
)}
{activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && (