fix(ui): восстанавливать контекст OSPF из URL

This commit is contained in:
Denozordec committed 2026-09-30 19:58:43 +07:00
1 parent af05ad5cda
commit 1d522527b4
7 files changed
+130 -44

No files matched your search

+51 -43
View File
@@ -2,7 +2,9 @@
import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost"
import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state"
import { useMemo, useState, useEffect, useRef } from "react"
import { useMemo, useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { readOspfUrl, patchOspfUrl, type OspfTab } from "@/lib/ospf-url-state"
import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { PageHeader } from "@/components/page-header"
@@ -30,7 +32,6 @@ import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
// ─── types ────────────────────────────────────────────────────────────────────
type OspfTab = "interfaces" | "neighbors" | "routes" | "bfd"
interface OspfServerInfo {
id: string // String(serverId) in live mode, routerKey in mock
@@ -632,7 +633,6 @@ function InterfacesTab({
isLive,
filterServerId,
backendUrl,
onLiveDataRefresh,
dataReady,
dataIncomplete,
onOperationBusy,
@@ -642,7 +642,6 @@ function InterfacesTab({
isLive: boolean
filterServerId: string
backendUrl: string
onLiveDataRefresh: () => void
dataReady: boolean
dataIncomplete: boolean
onOperationConfirmed: (id: number) => void
@@ -720,24 +719,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)
onOperationConfirmed(data.serverId)
if (!mounted.current) return
setLiveOptimalCost(byKey)
toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`)
onLiveDataRefresh()
} catch (err) {
if (!mounted.current) return
setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`)
onLiveDataRefresh()
} finally {
operationLock.current = false
if (mounted.current) { setOptimizing(false); onOperationBusy(false) }
if (mounted.current) setOptimizing(false)
onOperationBusy(false)
}
}
@@ -1112,13 +1110,19 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
]
export default function OspfPage() {
const { mode, backendUrl } = useDataSource()
return <OspfPageContent key={JSON.stringify([mode, backendUrl])} />
return <Suspense fallback={<p role="status">Загрузка контекста OSPF…</p>}><OspfRoute /></Suspense>
}
function OspfPageContent() {
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
function OspfRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const state = readOspfUrl(new URLSearchParams(params.toString()))
function navigate(values: Record<string, string | null>) {
const href = patchOspfUrl(new URLSearchParams(window.location.search), values)
if (href !== window.location.pathname + window.location.search) window.history.pushState(null, "", href)
}
return <OspfPageContent key={JSON.stringify([mode, backendUrl])} filterServerId={state.server} activeTab={state.tab} contextError={state.error} onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id })} onSelectTab={tab => navigate({ tab: tab === "interfaces" ? null : tab })} onResetContext={() => navigate({ server: null, tab: null })} />
}
function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServer, onSelectTab, onResetContext }: { filterServerId: string; activeTab: OspfTab; contextError: string | null; onSelectServer: (id: string) => void; onSelectTab: (tab: OspfTab) => void; onResetContext: () => void }) {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
@@ -1130,15 +1134,12 @@ function OspfPageContent() {
const [fetchTick, setFetchTick] = useState(0)
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
const [writeReceipt, setWriteReceipt] = useState<{ serverId: number; at: string } | null>(null)
const pageMounted = useRef(true)
useEffect(() => { pageMounted.current = true; return () => { pageMounted.current = false } }, [])
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(() => {
queueMicrotask(() => setFilterServerId("all"))
}, [isLive])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => setLiveData(null))
@@ -1172,8 +1173,8 @@ function OspfPageContent() {
}, [isLive, backendUrl, fetchTick])
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 && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null))
const incomplete = Boolean(contextError) || isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId))))
const dataReady = !contextError && (!isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null)))
// Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
@@ -1246,10 +1247,13 @@ function OspfPageContent() {
}, [items, neighbors, bfdSessions])
// ── filtered display data ─────────────────────────────────────────────────────
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const displayRoutes = filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
const missingServer = filterServerId !== ALL_SERVERS_ID && (!isLive || Boolean(liveData)) && !ospfServers.some(server => server.id === filterServerId)
const unavailable = Boolean(contextError) || missingServer
const viewIncomplete = incomplete || unavailable
const displayItems = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const displayRoutes = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
ospfServers.map((s) => {
@@ -1280,7 +1284,7 @@ function OspfPageContent() {
preserveSelection
items={ospfRailItems}
selectedId={filterServerId}
onSelect={id => { if (!operationBusy.current) setFilterServerId(id) }}
onSelect={id => { if (!operationBusy.current) onSelectServer(id) }}
showAll
allCount={ospfServers.length}
loading={isLive && loading && ospfServers.length === 0}
@@ -1302,7 +1306,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} disabled={writeBusy} onClick={() => { if (!operationBusy.current) setActiveTab(t.id) }}
<button key={t.id} aria-pressed={activeTab === t.id} disabled={writeBusy} onClick={() => { if (!operationBusy.current) onSelectTab(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
@@ -1318,6 +1322,7 @@ function OspfPageContent() {
>
<div className="flex flex-col gap-5">
{unavailable && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Контекст OSPF недоступен</AlertTitle><AlertDescription>{contextError ?? `Сервер ${filterServerId} отсутствует среди доступных устройств.`}<Button type="button" variant="outline" size="sm" disabled={writeBusy} onClick={onResetContext}>Сбросить контекст OSPF</Button></AlertDescription></Alert>}
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -1330,7 +1335,7 @@ function OspfPageContent() {
{writeReceipt && (filterServerId === ALL_SERVERS_ID || String(writeReceipt.serverId) === filterServerId) && <p data-ospf-time="write">API подтвердил оптимизацию устройства {writeReceipt.serverId}: <time dateTime={writeReceipt.at}>{new Date(writeReceipt.at).toLocaleTimeString("ru")}</time></p>}
</div>}
{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>}
{viewIncomplete && (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">
Моковые данные
@@ -1343,54 +1348,57 @@ function OspfPageContent() {
{
id: "routers",
label: "Роутеров",
value: incomplete ? "—" : totalRouters,
hint: incomplete ? "неизвестно" : undefined,
value: viewIncomplete || unavailable ? "—" : totalRouters,
hint: viewIncomplete || unavailable ? "неизвестно" : undefined,
icon: <RouterIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ifaces",
label: "Интерфейсов",
value: incomplete ? "—" : totalInterfaces,
value: viewIncomplete ? "—" : totalInterfaces,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
value: incomplete ? "—" : totalAreas,
value: viewIncomplete ? "—" : totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && (
{activeTab === "interfaces" && (!viewIncomplete || displayItems.length > 0) && (
<InterfacesTab key={filterServerId}
items={displayItems}
isLive={isLive}
filterServerId={filterServerId}
backendUrl={backendUrl}
onLiveDataRefresh={() => { setLoading(true); setFetchTick(t => t + 1) }}
dataReady={dataReady}
dataIncomplete={incomplete}
onOperationConfirmed={serverId => setWriteReceipt({ serverId, at: new Date().toISOString() })}
onOperationBusy={busy => { operationBusy.current = busy; setWriteBusy(busy) }}
dataIncomplete={viewIncomplete || unavailable}
onOperationConfirmed={serverId => { if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }}
onOperationBusy={busy => {
if (!pageMounted.current) return
operationBusy.current = busy; setWriteBusy(busy)
if (!busy) { setLoading(true); setFetchTick(t => t + 1) }
}}
/>
)}
{activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && (
{activeTab === "neighbors" && (!viewIncomplete || displayNeighbors.length > 0) && (
<NeighborsTab
neighbors={displayNeighbors}
items={displayItems}
graphNodes={graphNodes}
graphEdges={graphEdges}
routerIds={routerIds}
dataIncomplete={incomplete}
graphUnknown={isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
dataIncomplete={viewIncomplete}
graphUnknown={unavailable || isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
/>
)}
{activeTab === "routes" && (!incomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && (!incomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={incomplete} />}
{activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={viewIncomplete} />}
</div>
</ServerRailLayout>