fix(ui): сохранять поиск и выбранный узел OSPF
This commit is contained in:
1 parent
1d522527b4
commit
7a68ae8184
9 files changed
+113
-32
No files matched your search
+47
-25
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user