fix(ui): восстанавливать контекст OSPF из URL
This commit is contained in:
1 parent
af05ad5cda
commit
1d522527b4
7 files changed
+130
-44
No files matched your search
+51
-43
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost"
|
import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost"
|
||||||
import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state"
|
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 { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
@@ -30,7 +32,6 @@ import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
|||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type OspfTab = "interfaces" | "neighbors" | "routes" | "bfd"
|
|
||||||
|
|
||||||
interface OspfServerInfo {
|
interface OspfServerInfo {
|
||||||
id: string // String(serverId) in live mode, routerKey in mock
|
id: string // String(serverId) in live mode, routerKey in mock
|
||||||
@@ -632,7 +633,6 @@ function InterfacesTab({
|
|||||||
isLive,
|
isLive,
|
||||||
filterServerId,
|
filterServerId,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
onLiveDataRefresh,
|
|
||||||
dataReady,
|
dataReady,
|
||||||
dataIncomplete,
|
dataIncomplete,
|
||||||
onOperationBusy,
|
onOperationBusy,
|
||||||
@@ -642,7 +642,6 @@ function InterfacesTab({
|
|||||||
isLive: boolean
|
isLive: boolean
|
||||||
filterServerId: string
|
filterServerId: string
|
||||||
backendUrl: string
|
backendUrl: string
|
||||||
onLiveDataRefresh: () => void
|
|
||||||
dataReady: boolean
|
dataReady: boolean
|
||||||
dataIncomplete: boolean
|
dataIncomplete: boolean
|
||||||
onOperationConfirmed: (id: number) => void
|
onOperationConfirmed: (id: number) => void
|
||||||
@@ -720,24 +719,23 @@ function InterfacesTab({
|
|||||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!mounted.current) return
|
|
||||||
const data = ospfOptimizeSchema.parse(response)
|
const data = ospfOptimizeSchema.parse(response)
|
||||||
if (String(data.serverId) !== filterServerId) throw new Error("API вернул другой контекст OSPF")
|
if (String(data.serverId) !== filterServerId) throw new Error("API вернул другой контекст OSPF")
|
||||||
const byKey: Record<string, number> = {}
|
const byKey: Record<string, number> = {}
|
||||||
data.interfaces.forEach((row) => {
|
data.interfaces.forEach((row) => {
|
||||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||||
})
|
})
|
||||||
setLiveOptimalCost(byKey)
|
|
||||||
onOperationConfirmed(data.serverId)
|
onOperationConfirmed(data.serverId)
|
||||||
|
if (!mounted.current) return
|
||||||
|
setLiveOptimalCost(byKey)
|
||||||
toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`)
|
toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`)
|
||||||
onLiveDataRefresh()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted.current) return
|
if (!mounted.current) return
|
||||||
setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`)
|
setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`)
|
||||||
onLiveDataRefresh()
|
|
||||||
} finally {
|
} finally {
|
||||||
operationLock.current = false
|
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() {
|
export default function OspfPage() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
return <Suspense fallback={<p role="status">Загрузка контекста OSPF…</p>}><OspfRoute /></Suspense>
|
||||||
return <OspfPageContent key={JSON.stringify([mode, backendUrl])} />
|
|
||||||
}
|
}
|
||||||
function OspfPageContent() {
|
function OspfRoute() {
|
||||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
const { mode, backendUrl } = useDataSource()
|
||||||
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
|
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 { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
@@ -1130,15 +1134,12 @@ function OspfPageContent() {
|
|||||||
const [fetchTick, setFetchTick] = useState(0)
|
const [fetchTick, setFetchTick] = useState(0)
|
||||||
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
|
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
|
||||||
const [writeReceipt, setWriteReceipt] = useState<{ serverId: number; at: string } | null>(null)
|
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 operationBusy = useRef(false)
|
||||||
const [writeBusy, setWriteBusy] = useState(false)
|
const [writeBusy, setWriteBusy] = useState(false)
|
||||||
function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) }
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
queueMicrotask(() => setLiveData(null))
|
queueMicrotask(() => setLiveData(null))
|
||||||
@@ -1172,8 +1173,8 @@ function OspfPageContent() {
|
|||||||
}, [isLive, backendUrl, fetchTick])
|
}, [isLive, backendUrl, fetchTick])
|
||||||
|
|
||||||
const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData])
|
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 incomplete = Boolean(contextError) || 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 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
|
// Derive frontend types from backend data or use mocks
|
||||||
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
|
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
|
||||||
@@ -1246,10 +1247,13 @@ function OspfPageContent() {
|
|||||||
}, [items, neighbors, bfdSessions])
|
}, [items, neighbors, bfdSessions])
|
||||||
|
|
||||||
// ── filtered display data ─────────────────────────────────────────────────────
|
// ── filtered display data ─────────────────────────────────────────────────────
|
||||||
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
|
const missingServer = filterServerId !== ALL_SERVERS_ID && (!isLive || Boolean(liveData)) && !ospfServers.some(server => server.id === filterServerId)
|
||||||
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
const unavailable = Boolean(contextError) || missingServer
|
||||||
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
|
const viewIncomplete = incomplete || unavailable
|
||||||
const displayRoutes = filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
|
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[]>(() => (
|
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
ospfServers.map((s) => {
|
ospfServers.map((s) => {
|
||||||
@@ -1280,7 +1284,7 @@ function OspfPageContent() {
|
|||||||
preserveSelection
|
preserveSelection
|
||||||
items={ospfRailItems}
|
items={ospfRailItems}
|
||||||
selectedId={filterServerId}
|
selectedId={filterServerId}
|
||||||
onSelect={id => { if (!operationBusy.current) setFilterServerId(id) }}
|
onSelect={id => { if (!operationBusy.current) onSelectServer(id) }}
|
||||||
showAll
|
showAll
|
||||||
allCount={ospfServers.length}
|
allCount={ospfServers.length}
|
||||||
loading={isLive && loading && ospfServers.length === 0}
|
loading={isLive && loading && ospfServers.length === 0}
|
||||||
@@ -1302,7 +1306,7 @@ function OspfPageContent() {
|
|||||||
<div className="border-b bg-background shrink-0">
|
<div className="border-b bg-background shrink-0">
|
||||||
<div className="flex items-center px-6">
|
<div className="flex items-center px-6">
|
||||||
{TABS.map(t => (
|
{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(
|
className={cn(
|
||||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
activeTab === t.id
|
activeTab === t.id
|
||||||
@@ -1318,6 +1322,7 @@ function OspfPageContent() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<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 */}
|
{/* data source banner */}
|
||||||
{isLive && loading && (
|
{isLive && loading && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<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>}
|
{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>}
|
</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>}
|
{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" && (
|
{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">
|
<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",
|
id: "routers",
|
||||||
label: "Роутеров",
|
label: "Роутеров",
|
||||||
value: incomplete ? "—" : totalRouters,
|
value: viewIncomplete || unavailable ? "—" : totalRouters,
|
||||||
hint: incomplete ? "неизвестно" : undefined,
|
hint: viewIncomplete || unavailable ? "неизвестно" : undefined,
|
||||||
icon: <RouterIcon className="size-4" />,
|
icon: <RouterIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ifaces",
|
id: "ifaces",
|
||||||
label: "Интерфейсов",
|
label: "Интерфейсов",
|
||||||
value: incomplete ? "—" : totalInterfaces,
|
value: viewIncomplete ? "—" : totalInterfaces,
|
||||||
icon: <NetworkIcon className="size-4" />,
|
icon: <NetworkIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "areas",
|
id: "areas",
|
||||||
label: "Зон (Area)",
|
label: "Зон (Area)",
|
||||||
value: incomplete ? "—" : totalAreas,
|
value: viewIncomplete ? "—" : totalAreas,
|
||||||
icon: <LayersIcon className="size-4" />,
|
icon: <LayersIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && (
|
{activeTab === "interfaces" && (!viewIncomplete || displayItems.length > 0) && (
|
||||||
<InterfacesTab key={filterServerId}
|
<InterfacesTab key={filterServerId}
|
||||||
items={displayItems}
|
items={displayItems}
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
filterServerId={filterServerId}
|
filterServerId={filterServerId}
|
||||||
backendUrl={backendUrl}
|
backendUrl={backendUrl}
|
||||||
onLiveDataRefresh={() => { setLoading(true); setFetchTick(t => t + 1) }}
|
|
||||||
dataReady={dataReady}
|
dataReady={dataReady}
|
||||||
dataIncomplete={incomplete}
|
dataIncomplete={viewIncomplete || unavailable}
|
||||||
onOperationConfirmed={serverId => setWriteReceipt({ serverId, at: new Date().toISOString() })}
|
onOperationConfirmed={serverId => { if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }}
|
||||||
onOperationBusy={busy => { operationBusy.current = busy; setWriteBusy(busy) }}
|
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
|
<NeighborsTab
|
||||||
neighbors={displayNeighbors}
|
neighbors={displayNeighbors}
|
||||||
items={displayItems}
|
items={displayItems}
|
||||||
graphNodes={graphNodes}
|
graphNodes={graphNodes}
|
||||||
graphEdges={graphEdges}
|
graphEdges={graphEdges}
|
||||||
routerIds={routerIds}
|
routerIds={routerIds}
|
||||||
dataIncomplete={incomplete}
|
dataIncomplete={viewIncomplete}
|
||||||
graphUnknown={isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
|
graphUnknown={unavailable || isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "routes" && (!incomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} />}
|
{activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} />}
|
||||||
{activeTab === "bfd" && (!incomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={incomplete} />}
|
{activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={viewIncomplete} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|||||||
@@ -26,11 +26,18 @@
|
|||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.19, nullable интерфейсы/метрики OSPF; далее URL/layout |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.20, URL устройства/вкладки OSPF; далее поиск/layout |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 10.20 — URL-контекст и завершение OSPF-записи после history (2026-09-30)
|
||||||
|
|
||||||
|
- Устройство/вкладка контролируются URL; reload/back восстанавливают выбор, другие параметры сохранены. Повторные/невалидные параметры и отсутствующее устройство дают явный alert, не раскрывают общий каталог и не показывают нулевую сводку; reset исправляет только этот контекст.
|
||||||
|
- После history во время POST новая форма остаётся заблокированной до завершения прежней операции и повторного опроса. Завершение всегда освобождает lock живого родителя; после смены источника/unmount callback игнорируется. Поздний результат не выдаёт toast в новой форме, подтверждённый receipt привязан к исходному устройству.
|
||||||
|
- Проверки: 17 URL/operations/freshness/interface-values E2E, восемь source read/write E2E и два unit прошли. TS чистый, lint один старый warning; первый unit-запуск дал системный ENOMEM до теста, повторный прошёл. Next Suspense добавлен для URL-контекста; production проверить после следующего блока.
|
||||||
|
- Далее поиск/контекст выбранного объекта, затем desktop/mobile и клавиатура. Использовано 10% окна; локальный коммит без push.
|
||||||
|
|
||||||
## Блок 10.19 — интерфейсы и метрики строгого OSPF-каталога (2026-09-30)
|
## Блок 10.19 — интерфейсы и метрики строгого OSPF-каталога (2026-09-30)
|
||||||
|
|
||||||
- Interface cost/priority/timers/type/disabled/inactive/useBfd и instance version/routerId/flags допускают null без legacy defaults. Оптимизация запрещена при неизвестных costs/состояниях выбранного устройства. DTO frontend выводятся из Zod, вместо дублирующих интерфейсов.
|
- Interface cost/priority/timers/type/disabled/inactive/useBfd и instance version/routerId/flags допускают null без legacy defaults. Оптимизация запрещена при неизвестных costs/состояниях выбранного устройства. DTO frontend выводятся из Zod, вместо дублирующих интерфейсов.
|
||||||
|
|||||||
@@ -128,3 +128,7 @@ Lock/readiness, существующее mm:servers:write, валидация п
|
|||||||
## 10.19. Nullable интерфейсы и метрики
|
## 10.19. Nullable интерфейсы и метрики
|
||||||
|
|
||||||
Строгий catalog DTO сохраняет неизвестные поля interface/instance; route metric не подменяется distance. Наблюдаемый interface cost=0 сохранён согласно [RouterOS OSPF](https://help.mikrotik.com/docs/spaces/ROS/pages/331612216/routing%2Bospf); политика записи min=1 прежняя. При unknown costs/flags операция не предлагается. 21 E2E, 4 unit, backend regression и обе production-сборки прошли. Далее URL/layout и дополнительная проверка метаданных.
|
Строгий catalog DTO сохраняет неизвестные поля interface/instance; route metric не подменяется distance. Наблюдаемый interface cost=0 сохранён согласно [RouterOS OSPF](https://help.mikrotik.com/docs/spaces/ROS/pages/331612216/routing%2Bospf); политика записи min=1 прежняя. При unknown costs/flags операция не предлагается. 21 E2E, 4 unit, backend regression и обе production-сборки прошли. Далее URL/layout и дополнительная проверка метаданных.
|
||||||
|
|
||||||
|
## 10.20. URL и завершение записи после history
|
||||||
|
|
||||||
|
Device/tab сохранены в URL; invalid/missing context не расширяет срез. Unmounted child освобождает lock только живого родителя, который перечитывает каталог; source unmount игнорирует старый callback. 17 основных + 8 source E2E, два unit, TS/lint прошли. Далее search/object context/layout.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { test } from "node:test"
|
||||||
|
import { readOspfUrl, patchOspfUrl } from "./ospf-url-state"
|
||||||
|
test("OSPF device/tab URL preserves unrelated context", () => {
|
||||||
|
const url=patchOspfUrl(new URLSearchParams("keep=x"),{server:"2",tab:"bfd"})
|
||||||
|
assert.deepEqual(readOspfUrl(new URL(url,"http://test").searchParams),{server:"2",tab:"bfd",error:null})
|
||||||
|
assert.equal(patchOspfUrl(new URL(url,"http://test").searchParams,{server:null,tab:null}),"/ospf?keep=x")
|
||||||
|
})
|
||||||
|
test("invalid or repeated context is explicit instead of widening to all",()=>{
|
||||||
|
for(const raw of ["server=a&server=b","tab=bfd&tab=neighbors","tab=broken","server=", "server="+"x".repeat(65),"server=%2F"]) assert.ok(readOspfUrl(new URLSearchParams(raw)).error)
|
||||||
|
assert.equal(readOspfUrl(new URLSearchParams("server=srv1&tab=neighbors")).error,null)
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const ospfTabs = ["interfaces", "neighbors", "routes", "bfd"] as const
|
||||||
|
export type OspfTab = typeof ospfTabs[number]
|
||||||
|
export function readOspfUrl(params: URLSearchParams) {
|
||||||
|
const server = params.get("server") ?? "all"
|
||||||
|
const rawTab = params.get("tab") ?? "interfaces"
|
||||||
|
let error: string | null = null
|
||||||
|
if (["server", "tab"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяется контекст OSPF."
|
||||||
|
else if (!/^[a-zA-Z0-9_.:-]{1,64}$/.test(server)) error = "В ссылке указан некорректный сервер OSPF."
|
||||||
|
else if (!(ospfTabs as readonly string[]).includes(rawTab)) error = "В ссылке указана неизвестная вкладка OSPF."
|
||||||
|
return { server, tab: (ospfTabs as readonly string[]).includes(rawTab) ? rawTab as OspfTab : "interfaces" as OspfTab, error }
|
||||||
|
}
|
||||||
|
export function patchOspfUrl(params: URLSearchParams, changes: Record<string, string | null>) {
|
||||||
|
const next = new URLSearchParams(params)
|
||||||
|
for (const [key,value] of Object.entries(changes)) { if (value === null || value === "") next.delete(key); else next.set(key,value) }
|
||||||
|
return "/ospf" + (next.size ? "?" + next.toString() : "")
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ for (const change of ["backend", "mode"]) for (const operation of ["read", "writ
|
|||||||
await page.getByRole("button", { name: operation === "read" ? "Обновить" : "Оптимизировать OSPF", exact: true }).click()
|
await page.getByRole("button", { name: operation === "read" ? "Обновить" : "Оптимизировать OSPF", exact: true }).click()
|
||||||
await expect.poll(() => started).toBe(true)
|
await expect.poll(() => started).toBe(true)
|
||||||
await page.goBack()
|
await page.goBack()
|
||||||
|
await page.goBack()
|
||||||
await expect(page).toHaveURL(/settings/)
|
await expect(page).toHaveURL(/settings/)
|
||||||
if (change === "backend") {
|
if (change === "backend") {
|
||||||
const input = page.getByPlaceholder("http://localhost:8000")
|
const input = page.getByPlaceholder("http://localhost:8000")
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test"
|
||||||
|
const host=(id:number)=>({serverId:id,serverName:"Роутер "+id,serverSite:"",serverCountry:"RU"})
|
||||||
|
const iface=(id:number)=>({...host(id),id:"*1",instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether"+id,cost:20,type:"broadcast",disabled:false,inactive:false,priority:1,helloInterval:10,deadInterval:40,useBfd:false})
|
||||||
|
const catalog={interfaces:[iface(1),iface(2)],neighbors:[],instances:[],bfdSessions:[],routes:[],failures:[]}
|
||||||
|
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==="/api/ospf/catalog"?catalog:path==="/api/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,country:"RU",enabled:true,status:null})):path==="/api/auth/config"?{required:false}:{}})})}
|
||||||
|
test("OSPF: устройство и вкладка переживают reload/back",async({page})=>{
|
||||||
|
await setup(page);await page.goto("/ospf?server=2&tab=bfd&keep=x")
|
||||||
|
await expect(page.getByRole("button",{name:"BFD",exact:true})).toHaveAttribute("aria-pressed","true")
|
||||||
|
await expect(page.getByRole("option").filter({hasText:"Роутер 2"})).toHaveAttribute("aria-selected","true")
|
||||||
|
await page.getByRole("button",{name:"Интерфейсы",exact:true}).click()
|
||||||
|
await expect(page.getByText("ether2",{exact:true})).toBeVisible();await expect(page.getByText("ether1",{exact:true})).toHaveCount(0)
|
||||||
|
await page.reload();await expect(page.getByText("ether2",{exact:true})).toBeVisible()
|
||||||
|
await page.getByRole("option").filter({hasText:"Роутер 1"}).click()
|
||||||
|
await expect(page).toHaveURL(/server=1/);await expect(page).toHaveURL(/keep=x/)
|
||||||
|
await page.goBack();await expect(page.getByText("ether2",{exact:true})).toBeVisible()
|
||||||
|
await page.goBack();await expect(page.getByRole("button",{name:"BFD",exact:true})).toHaveAttribute("aria-pressed","true")
|
||||||
|
})
|
||||||
|
for(const context of ["server=999","tab=broken","server=1&server=2","tab=bfd&tab=routes"])test("OSPF: недоступный URL не показывает общий каталог "+context,async({page})=>{
|
||||||
|
await setup(page);await page.goto("/ospf?"+context+"&keep=x")
|
||||||
|
const alert=page.getByRole("alert").filter({hasText:"Контекст OSPF недоступен"});await expect(alert).toBeVisible()
|
||||||
|
await expect(page.getByText("ether1",{exact:true})).toHaveCount(0)
|
||||||
|
await expect(page.getByLabel("Сводка OSPF",{exact:true})).toContainText("неизвестно")
|
||||||
|
await alert.getByRole("button",{name:"Сбросить контекст OSPF"}).click()
|
||||||
|
await expect(page).toHaveURL(/keep=x/);await expect(alert).toHaveCount(0);await expect(page.getByText("ether1",{exact:true})).toBeVisible()
|
||||||
|
})
|
||||||
|
for(const fails of [false,true])test("OSPF: history во время записи освобождает lock "+fails,async({page})=>{
|
||||||
|
await setup(page);let writes=0,started=false,reads=0;let release!:()=>void;const gate=new Promise<void>(resolve=>{release=resolve})
|
||||||
|
await page.route("**/api/ospf/catalog",r=>{reads++;return r.fulfill({json:catalog})})
|
||||||
|
await page.route("**/api/servers/1/ospf/optimize",async r=>{writes++;started=true;await gate;return r.fulfill(fails?{status:503,json:{error:"Старая ошибка"}}:{json:{serverId:1,serverName:"Роутер 1",pingWeight:60,optimizedCount:0,applied:[],interfaces:[]}})})
|
||||||
|
await page.goto("/ospf");await page.getByRole("option").filter({hasText:"Роутер 1"}).click()
|
||||||
|
await page.getByRole("button",{name:"Оптимизировать OSPF",exact:true}).click();await expect.poll(()=>started).toBe(true)
|
||||||
|
await page.goBack();await expect(page).not.toHaveURL(/server=1/)
|
||||||
|
await page.goForward();const optimize=page.getByRole("button",{name:"Оптимизировать OSPF",exact:true});await expect(optimize).toBeDisabled()
|
||||||
|
const before=reads;const response=page.waitForResponse(r=>r.url().includes("/api/servers/1/ospf/optimize"));release();await (await response).finished()
|
||||||
|
await expect.poll(()=>reads).toBe(before+1);await expect(optimize).toBeEnabled();expect(writes).toBe(1)
|
||||||
|
await expect(page.getByText("Старая ошибка",{exact:false})).toHaveCount(0)
|
||||||
|
await expect(page.locator("[data-sonner-toast]").filter({hasText:"API подтвердил оптимизацию OSPF"})).toHaveCount(0)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user