fix(ui): сохранять поиск и выбранный узел OSPF

This commit is contained in:
Denozordec committed 2026-09-30 20:09:00 +07:00
1 parent 1d522527b4
commit 7a68ae8184
9 files changed
+113 -32

No files matched your search

+47 -25
View File
@@ -7,6 +7,7 @@ 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 { DataPageToolbar } from "@/components/data-page-toolbar"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
@@ -556,7 +557,7 @@ function NodeDetailPanel({
node.site === "FRA" ? "#b45309" : "#065f46"
return (
<div className="w-72 shrink-0 flex flex-col overflow-y-auto border-l border-white/10"
<div role="region" aria-label="Детали узла OSPF" className="w-72 shrink-0 flex flex-col overflow-y-auto border-l border-white/10"
style={{ background: "#060d1a", maxHeight: TOPO_H }}>
<div className="flex items-start gap-2 px-4 py-3 border-b border-white/10 shrink-0">
<div className="size-8 rounded-full flex items-center justify-center shrink-0 mt-0.5 text-[10px] font-bold text-white"
@@ -567,7 +568,7 @@ function NodeDetailPanel({
<p className="text-sm font-mono font-semibold text-[#f1f5f9] truncate leading-tight">{node.label}</p>
<p className="text-[10px] font-mono text-[#64748b] mt-0.5">Router-ID: {routerId}</p>
</div>
<button onClick={onClose} className="text-white/25 hover:text-white/70 transition-colors mt-0.5 shrink-0">
<button type="button" aria-label="Закрыть" onClick={onClose} className="text-white/25 hover:text-white/70 transition-colors mt-0.5 shrink-0">
<XIcon className="size-3.5" />
</button>
</div>
@@ -635,6 +636,7 @@ function InterfacesTab({
backendUrl,
dataReady,
dataIncomplete,
hasSearch,
onOperationBusy,
onOperationConfirmed,
}: {
@@ -644,6 +646,7 @@ function InterfacesTab({
backendUrl: string
dataReady: boolean
dataIncomplete: boolean
hasSearch: boolean
onOperationConfirmed: (id: number) => void
onOperationBusy: (busy: boolean) => void
}) {
@@ -762,6 +765,7 @@ function InterfacesTab({
return (
<div className="flex flex-col gap-4">
{isLive && filterServerId !== ALL_SERVERS_ID && <p className="text-xs text-muted-foreground">Оптимизация применяется ко всем интерфейсам выбранного устройства.</p>}
{isLive && filterServerId !== ALL_SERVERS_ID && !dataReady && <p role="status" className="text-xs text-muted-foreground">Оптимизация недоступна до полного успешного опроса и подтверждения costs/состояний интерфейсов с правом записи.</p>}
{operationError && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Оптимизация OSPF не подтверждена</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
<div className="flex items-center gap-2 flex-wrap">
@@ -784,7 +788,7 @@ function InterfacesTab({
{grouped.length === 0 && !dataIncomplete && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
OSPF интерфейсы не настроены ни на одном сервере
{hasSearch ? "Нет интерфейсов по текущему поиску" : "OSPF интерфейсы не настроены ни на одном сервере"}
</div>
)}
@@ -887,7 +891,7 @@ function InterfacesTab({
// ─── neighbors tab ────────────────────────────────────────────────────────────
function NeighborsTab({
neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown,
neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown, selectedId, onSelect, hasSearch,
}: {
neighbors: OspfNeighbor[]
items: OspfItem[]
@@ -896,9 +900,11 @@ function NeighborsTab({
routerIds: Record<string, string>
dataIncomplete: boolean
graphUnknown: boolean
selectedId: string | null
onSelect: (id: string | null) => void
hasSearch: boolean
}) {
const [highlightId, setHighlightId] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [showDots, setShowDots] = useState(true)
dataIncomplete = dataIncomplete || neighbors.some(n => n.state === "Unknown")
@@ -934,6 +940,7 @@ function NeighborsTab({
]}
/>
{selectedId && !graphUnknown && !graphNodes.some(node => node.id === selectedId) && <Alert variant="warning"><AlertTitle>Узел OSPF недоступен</AlertTitle><AlertDescription>Узел из ссылки отсутствует в текущем срезе.<Button type="button" size="sm" variant="outline" onClick={() => onSelect(null)}>Закрыть узел OSPF</Button></AlertDescription></Alert>}
{graphUnknown && <p role="status" className="text-sm text-muted-foreground">Топология OSPF неизвестна до успешного опроса всех устройств.</p>}
{!graphUnknown && graphNodes.length > 0 && (
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
@@ -946,7 +953,7 @@ function NeighborsTab({
highlightId={highlightId}
selectedId={selectedId}
showDots={showDots}
onNodeClick={setSelectedId}
onNodeClick={onSelect}
/>
<button
onClick={() => setShowDots(v => !v)}
@@ -972,7 +979,7 @@ function NeighborsTab({
routerIds={routerIds}
neighbors={neighbors}
items={items}
onClose={() => setSelectedId(null)}
onClose={() => onSelect(null)}
/>
)}
</div>
@@ -981,8 +988,9 @@ function NeighborsTab({
<DataPageCard>
<OspfNeighborsDataGrid
neighbors={neighbors}
emptyTitle={hasSearch ? "Нет соседей по текущему поиску" : undefined}
selectedRouterId={selectedId}
onSelect={setSelectedId}
onSelect={onSelect}
onHighlight={setHighlightId}
/>
</DataPageCard>
@@ -992,7 +1000,7 @@ function NeighborsTab({
// ─── routes tab (mock) ────────────────────────────────────────────────────────
function RoutesTab({ routes }: { routes: OspfRoute[] }) {
function RoutesTab({ routes, hasSearch }: { routes: OspfRoute[]; hasSearch: boolean }) {
const typeCount = Object.entries(
routes.reduce<Record<string, number>>((acc, r) => { acc[r.type] = (acc[r.type] ?? 0) + 1; return acc }, {})
)
@@ -1007,7 +1015,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
))}
</div>
<DataPageCard>
<OspfRoutesDataGrid routes={routes} />
<OspfRoutesDataGrid routes={routes} emptyTitle={hasSearch ? "Нет маршрутов по текущему поиску" : undefined} />
</DataPageCard>
<div className="flex items-center gap-5 flex-wrap px-1">
<span className="text-xs text-muted-foreground">Типы:</span>
@@ -1029,7 +1037,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
// ─── BFD tab ──────────────────────────────────────────────────────────────────
function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataIncomplete: boolean }) {
function BfdTab({ sessions, dataIncomplete, hasSearch }: { sessions: BfdSession[]; dataIncomplete: boolean; hasSearch: boolean }) {
dataIncomplete = dataIncomplete || sessions.some(b => b.state === "Unknown")
const upCount = sessions.filter(b => b.state === "Up").length
const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").length
@@ -1076,7 +1084,7 @@ function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataInco
{sessions.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
BFD-сессий не обнаружено ни на одном сервере
{hasSearch ? "Нет BFD-сессий по текущему поиску" : "BFD-сессий не обнаружено ни на одном сервере"}
</div>
)}
@@ -1116,13 +1124,13 @@ function OspfRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const state = readOspfUrl(new URLSearchParams(params.toString()))
function navigate(values: Record<string, string | null>) {
function navigate(values: Record<string, string | null>, replace = false) {
const href = patchOspfUrl(new URLSearchParams(window.location.search), values)
if (href !== window.location.pathname + window.location.search) window.history.pushState(null, "", href)
if (href !== window.location.pathname + window.location.search) (replace ? window.history.replaceState : window.history.pushState).call(window.history, 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 })} />
return <OspfPageContent key={JSON.stringify([mode, backendUrl])} filterServerId={state.server} activeTab={state.tab} contextError={state.error} search={state.search} selectedNodeId={state.node} onSearch={value => navigate({ q: value }, true)} onSelectNode={node => navigate({ node })} onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, node: null })} onSelectTab={tab => navigate({ tab: tab === "interfaces" ? null : tab })} onResetContext={() => navigate({ server: null, tab: null, q: null, node: 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 }) {
function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filterServerId, activeTab, contextError, onSelectServer, onSelectTab, onResetContext }: { search: string; selectedNodeId: string | null; onSearch: (value: string) => void; onSelectNode: (id: string | null) => void; 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"
@@ -1250,10 +1258,19 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ
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 scopedItems = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const scopedNeighbors = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const scopedBfdSessions = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const scopedRoutes = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
const query = search.trim().toLocaleLowerCase("ru")
const matches = (...fields: (string | number | null | undefined)[]) => !query || fields.some(field => String(field ?? "").toLocaleLowerCase("ru").includes(query))
const displayItems = scopedItems.filter(i => matches(i.routerLabel, i.interfaceName, i.area, i.type))
const displayNeighbors = scopedNeighbors.filter(n => matches(n.localLabel, n.localIface, n.remoteRouterId, n.area, n.state))
const displayBfdSessions = scopedBfdSessions.filter(b => matches(b.serverLabel, b.iface, b.localAddr, b.remoteAddr, b.state))
const displayRoutes = scopedRoutes.filter(r => matches(r.serverLabel, r.destination, r.nextHop, r.via, r.area, r.type))
const displayGraph = isLive || filterServerId !== ALL_SERVERS_ID || query ? buildLiveGraph(displayNeighbors) : { nodes: graphNodes, edges: graphEdges }
const visibleCount = activeTab === "interfaces" ? displayItems.length : activeTab === "neighbors" ? displayNeighbors.length : activeTab === "bfd" ? displayBfdSessions.length : displayRoutes.length
const scopeCount = activeTab === "interfaces" ? scopedItems.length : activeTab === "neighbors" ? scopedNeighbors.length : activeTab === "bfd" ? scopedBfdSessions.length : scopedRoutes.length
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
ospfServers.map((s) => {
@@ -1323,6 +1340,7 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ
<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>}
<DataPageToolbar search={search} onSearchChange={onSearch} searchPlaceholder="Поиск в текущем разделе OSPF…" countLabel={viewIncomplete ? "Данные неизвестны" : `${visibleCount} из ${scopeCount}`} actions={search ? <Button type="button" variant="ghost" size="sm" onClick={() => onSearch("")}>Сбросить поиск</Button> : undefined} />
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -1377,7 +1395,8 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ
filterServerId={filterServerId}
backendUrl={backendUrl}
dataReady={dataReady}
dataIncomplete={viewIncomplete || unavailable}
hasSearch={Boolean(query)}
dataIncomplete={viewIncomplete}
onOperationConfirmed={serverId => { if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }}
onOperationBusy={busy => {
if (!pageMounted.current) return
@@ -1390,15 +1409,18 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ
<NeighborsTab
neighbors={displayNeighbors}
items={displayItems}
graphNodes={graphNodes}
graphEdges={graphEdges}
graphNodes={displayGraph.nodes}
graphEdges={displayGraph.edges}
selectedId={selectedNodeId}
onSelect={onSelectNode}
hasSearch={Boolean(query)}
routerIds={routerIds}
dataIncomplete={viewIncomplete}
graphUnknown={unavailable || isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
/>
)}
{activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={viewIncomplete} />}
{activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} hasSearch={Boolean(query)} />}
{activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={viewIncomplete} hasSearch={Boolean(query)} />}
</div>
</ServerRailLayout>