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 { 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 { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
|
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
|
||||||
@@ -556,7 +557,7 @@ function NodeDetailPanel({
|
|||||||
node.site === "FRA" ? "#b45309" : "#065f46"
|
node.site === "FRA" ? "#b45309" : "#065f46"
|
||||||
|
|
||||||
return (
|
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 }}>
|
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="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"
|
<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-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>
|
<p className="text-[10px] font-mono text-[#64748b] mt-0.5">Router-ID: {routerId}</p>
|
||||||
</div>
|
</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" />
|
<XIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -635,6 +636,7 @@ function InterfacesTab({
|
|||||||
backendUrl,
|
backendUrl,
|
||||||
dataReady,
|
dataReady,
|
||||||
dataIncomplete,
|
dataIncomplete,
|
||||||
|
hasSearch,
|
||||||
onOperationBusy,
|
onOperationBusy,
|
||||||
onOperationConfirmed,
|
onOperationConfirmed,
|
||||||
}: {
|
}: {
|
||||||
@@ -644,6 +646,7 @@ function InterfacesTab({
|
|||||||
backendUrl: string
|
backendUrl: string
|
||||||
dataReady: boolean
|
dataReady: boolean
|
||||||
dataIncomplete: boolean
|
dataIncomplete: boolean
|
||||||
|
hasSearch: boolean
|
||||||
onOperationConfirmed: (id: number) => void
|
onOperationConfirmed: (id: number) => void
|
||||||
onOperationBusy: (busy: boolean) => void
|
onOperationBusy: (busy: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -762,6 +765,7 @@ function InterfacesTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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>}
|
{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>}
|
{operationError && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Оптимизация OSPF не подтверждена</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -784,7 +788,7 @@ function InterfacesTab({
|
|||||||
|
|
||||||
{grouped.length === 0 && !dataIncomplete && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -887,7 +891,7 @@ function InterfacesTab({
|
|||||||
// ─── neighbors tab ────────────────────────────────────────────────────────────
|
// ─── neighbors tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NeighborsTab({
|
function NeighborsTab({
|
||||||
neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown,
|
neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown, selectedId, onSelect, hasSearch,
|
||||||
}: {
|
}: {
|
||||||
neighbors: OspfNeighbor[]
|
neighbors: OspfNeighbor[]
|
||||||
items: OspfItem[]
|
items: OspfItem[]
|
||||||
@@ -896,9 +900,11 @@ function NeighborsTab({
|
|||||||
routerIds: Record<string, string>
|
routerIds: Record<string, string>
|
||||||
dataIncomplete: boolean
|
dataIncomplete: boolean
|
||||||
graphUnknown: boolean
|
graphUnknown: boolean
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string | null) => void
|
||||||
|
hasSearch: boolean
|
||||||
}) {
|
}) {
|
||||||
const [highlightId, setHighlightId] = useState<string | null>(null)
|
const [highlightId, setHighlightId] = useState<string | null>(null)
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
||||||
const [showDots, setShowDots] = useState(true)
|
const [showDots, setShowDots] = useState(true)
|
||||||
|
|
||||||
dataIncomplete = dataIncomplete || neighbors.some(n => n.state === "Unknown")
|
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 && <p role="status" className="text-sm text-muted-foreground">Топология OSPF неизвестна до успешного опроса всех устройств.</p>}
|
||||||
{!graphUnknown && graphNodes.length > 0 && (
|
{!graphUnknown && graphNodes.length > 0 && (
|
||||||
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
||||||
@@ -946,7 +953,7 @@ function NeighborsTab({
|
|||||||
highlightId={highlightId}
|
highlightId={highlightId}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
showDots={showDots}
|
showDots={showDots}
|
||||||
onNodeClick={setSelectedId}
|
onNodeClick={onSelect}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDots(v => !v)}
|
onClick={() => setShowDots(v => !v)}
|
||||||
@@ -972,7 +979,7 @@ function NeighborsTab({
|
|||||||
routerIds={routerIds}
|
routerIds={routerIds}
|
||||||
neighbors={neighbors}
|
neighbors={neighbors}
|
||||||
items={items}
|
items={items}
|
||||||
onClose={() => setSelectedId(null)}
|
onClose={() => onSelect(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -981,8 +988,9 @@ function NeighborsTab({
|
|||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<OspfNeighborsDataGrid
|
<OspfNeighborsDataGrid
|
||||||
neighbors={neighbors}
|
neighbors={neighbors}
|
||||||
|
emptyTitle={hasSearch ? "Нет соседей по текущему поиску" : undefined}
|
||||||
selectedRouterId={selectedId}
|
selectedRouterId={selectedId}
|
||||||
onSelect={setSelectedId}
|
onSelect={onSelect}
|
||||||
onHighlight={setHighlightId}
|
onHighlight={setHighlightId}
|
||||||
/>
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
@@ -992,7 +1000,7 @@ function NeighborsTab({
|
|||||||
|
|
||||||
// ─── routes tab (mock) ────────────────────────────────────────────────────────
|
// ─── routes tab (mock) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RoutesTab({ routes }: { routes: OspfRoute[] }) {
|
function RoutesTab({ routes, hasSearch }: { routes: OspfRoute[]; hasSearch: boolean }) {
|
||||||
const typeCount = Object.entries(
|
const typeCount = Object.entries(
|
||||||
routes.reduce<Record<string, number>>((acc, r) => { acc[r.type] = (acc[r.type] ?? 0) + 1; return acc }, {})
|
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>
|
</div>
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<OspfRoutesDataGrid routes={routes} />
|
<OspfRoutesDataGrid routes={routes} emptyTitle={hasSearch ? "Нет маршрутов по текущему поиску" : undefined} />
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
<div className="flex items-center gap-5 flex-wrap px-1">
|
<div className="flex items-center gap-5 flex-wrap px-1">
|
||||||
<span className="text-xs text-muted-foreground">Типы:</span>
|
<span className="text-xs text-muted-foreground">Типы:</span>
|
||||||
@@ -1029,7 +1037,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
|
|||||||
|
|
||||||
// ─── BFD tab ──────────────────────────────────────────────────────────────────
|
// ─── 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")
|
dataIncomplete = dataIncomplete || sessions.some(b => b.state === "Unknown")
|
||||||
const upCount = sessions.filter(b => b.state === "Up").length
|
const upCount = sessions.filter(b => b.state === "Up").length
|
||||||
const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").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 && (
|
{sessions.length === 0 && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1116,13 +1124,13 @@ function OspfRoute() {
|
|||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const params = useSearchParams()
|
const params = useSearchParams()
|
||||||
const state = readOspfUrl(new URLSearchParams(params.toString()))
|
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)
|
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 { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
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 missingServer = filterServerId !== ALL_SERVERS_ID && (!isLive || Boolean(liveData)) && !ospfServers.some(server => server.id === filterServerId)
|
||||||
const unavailable = Boolean(contextError) || missingServer
|
const unavailable = Boolean(contextError) || missingServer
|
||||||
const viewIncomplete = incomplete || unavailable
|
const viewIncomplete = incomplete || unavailable
|
||||||
const displayItems = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
|
const scopedItems = 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 scopedNeighbors = 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 scopedBfdSessions = 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 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[]>(() => (
|
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
ospfServers.map((s) => {
|
ospfServers.map((s) => {
|
||||||
@@ -1323,6 +1340,7 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ
|
|||||||
<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>}
|
{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 */}
|
{/* 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">
|
||||||
@@ -1377,7 +1395,8 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ
|
|||||||
filterServerId={filterServerId}
|
filterServerId={filterServerId}
|
||||||
backendUrl={backendUrl}
|
backendUrl={backendUrl}
|
||||||
dataReady={dataReady}
|
dataReady={dataReady}
|
||||||
dataIncomplete={viewIncomplete || unavailable}
|
hasSearch={Boolean(query)}
|
||||||
|
dataIncomplete={viewIncomplete}
|
||||||
onOperationConfirmed={serverId => { if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }}
|
onOperationConfirmed={serverId => { if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }}
|
||||||
onOperationBusy={busy => {
|
onOperationBusy={busy => {
|
||||||
if (!pageMounted.current) return
|
if (!pageMounted.current) return
|
||||||
@@ -1390,15 +1409,18 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ
|
|||||||
<NeighborsTab
|
<NeighborsTab
|
||||||
neighbors={displayNeighbors}
|
neighbors={displayNeighbors}
|
||||||
items={displayItems}
|
items={displayItems}
|
||||||
graphNodes={graphNodes}
|
graphNodes={displayGraph.nodes}
|
||||||
graphEdges={graphEdges}
|
graphEdges={displayGraph.edges}
|
||||||
|
selectedId={selectedNodeId}
|
||||||
|
onSelect={onSelectNode}
|
||||||
|
hasSearch={Boolean(query)}
|
||||||
routerIds={routerIds}
|
routerIds={routerIds}
|
||||||
dataIncomplete={viewIncomplete}
|
dataIncomplete={viewIncomplete}
|
||||||
graphUnknown={unavailable || isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
|
graphUnknown={unavailable || isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} />}
|
{activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} hasSearch={Boolean(query)} />}
|
||||||
{activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={viewIncomplete} />}
|
{activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={viewIncomplete} hasSearch={Boolean(query)} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function stateClass(state: OspfNeighborRow["state"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface OspfNeighborsDataGridProps {
|
interface OspfNeighborsDataGridProps {
|
||||||
|
emptyTitle?: string
|
||||||
neighbors: OspfNeighborRow[]
|
neighbors: OspfNeighborRow[]
|
||||||
highlightRouterId?: string | null
|
highlightRouterId?: string | null
|
||||||
selectedRouterId?: string | null
|
selectedRouterId?: string | null
|
||||||
@@ -54,6 +55,7 @@ interface OspfNeighborsDataGridProps {
|
|||||||
|
|
||||||
function OspfNeighborsDataGrid({
|
function OspfNeighborsDataGrid({
|
||||||
neighbors,
|
neighbors,
|
||||||
|
emptyTitle,
|
||||||
highlightRouterId,
|
highlightRouterId,
|
||||||
selectedRouterId,
|
selectedRouterId,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -211,7 +213,7 @@ function OspfNeighborsDataGrid({
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<NetworkIcon className="size-4" />}
|
icon={<NetworkIcon className="size-4" />}
|
||||||
title="Нет OSPF-соседей"
|
title={emptyTitle ?? "Нет OSPF-соседей"}
|
||||||
className="border-0 py-10"
|
className="border-0 py-10"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,10 +39,11 @@ function routeTypeClass(type: OspfRouteRow["type"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface OspfRoutesDataGridProps {
|
interface OspfRoutesDataGridProps {
|
||||||
|
emptyTitle?: string
|
||||||
routes: OspfRouteRow[]
|
routes: OspfRouteRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
|
function OspfRoutesDataGrid({ routes, emptyTitle }: OspfRoutesDataGridProps) {
|
||||||
const columns = useMemo<ColumnDef<DataGridFeatures, OspfRouteRow>[]>(
|
const columns = useMemo<ColumnDef<DataGridFeatures, OspfRouteRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -153,7 +154,7 @@ function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<RouteIcon className="size-4" />}
|
icon={<RouteIcon className="size-4" />}
|
||||||
title="Нет OSPF-маршрутов"
|
title={emptyTitle ?? "Нет OSPF-маршрутов"}
|
||||||
className="border-0 py-10"
|
className="border-0 py-10"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface DataPageToolbarProps<T extends string = string> {
|
|||||||
search?: string
|
search?: string
|
||||||
onSearchChange?: (value: string) => void
|
onSearchChange?: (value: string) => void
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
|
searchLabel?: string
|
||||||
segmented?: {
|
segmented?: {
|
||||||
value: T
|
value: T
|
||||||
onChange: (value: T) => void
|
onChange: (value: T) => void
|
||||||
@@ -46,6 +47,7 @@ function DataPageToolbar<T extends string = string>({
|
|||||||
search,
|
search,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
searchPlaceholder = "Поиск…",
|
searchPlaceholder = "Поиск…",
|
||||||
|
searchLabel,
|
||||||
segmented,
|
segmented,
|
||||||
filterControl,
|
filterControl,
|
||||||
countLabel,
|
countLabel,
|
||||||
@@ -69,6 +71,7 @@ function DataPageToolbar<T extends string = string>({
|
|||||||
<SearchIcon className="size-3.5" />
|
<SearchIcon className="size-3.5" />
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
<InputGroupInput
|
<InputGroupInput
|
||||||
|
aria-label={searchLabel ?? searchPlaceholder}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={search ?? ""}
|
value={search ?? ""}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
|||||||
@@ -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.20, URL устройства/вкладки OSPF; далее поиск/layout |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.21, поиск и узел OSPF в URL; далее метаданные/layout |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 10.21 — поиск и выбранный узел OSPF (2026-09-30)
|
||||||
|
|
||||||
|
- Поиск текущего раздела и node сохраняются в URL, переживают reload/history. Устройство/таблица/топология используют текущий срез; invalid/repeated/oversized q/node блокируют контекст. Недоступный узел закрывается отдельным действием без сброса устройства.
|
||||||
|
- Пустой поиск отличается от отсутствия конфигурации. Подпись «N из M», сброс только поиска и доступное имя input добавлены через существующий DataPageToolbar. Область операции обозначена: все интерфейсы устройства, независимо от поиска; readiness проверяет полный каталог устройства.
|
||||||
|
- Проверки: 27 основных E2E плюс выбранный узел/reload/back (28 разных сценариев), три unit, TS/lint и production frontend (34 страницы) прошли. Новый тест нашёл безымянную кнопку закрытия; aria-label исправлен, сценарий повторно прошёл. Один прежний warning frontend. Компоненты ReUI/тема не изменены.
|
||||||
|
- Далее дополнительные неизвестные метаданные (тип маршрута, multihop, Router ID) и desktop/mobile/keyboard приёмка. Последнее чтение квоты: 14% окна использовано. Локальный коммит без push.
|
||||||
|
|
||||||
## Блок 10.20 — URL-контекст и завершение OSPF-записи после history (2026-09-30)
|
## Блок 10.20 — URL-контекст и завершение OSPF-записи после history (2026-09-30)
|
||||||
|
|
||||||
- Устройство/вкладка контролируются URL; reload/back восстанавливают выбор, другие параметры сохранены. Повторные/невалидные параметры и отсутствующее устройство дают явный alert, не раскрывают общий каталог и не показывают нулевую сводку; reset исправляет только этот контекст.
|
- Устройство/вкладка контролируются URL; reload/back восстанавливают выбор, другие параметры сохранены. Повторные/невалидные параметры и отсутствующее устройство дают явный alert, не раскрывают общий каталог и не показывают нулевую сводку; reset исправляет только этот контекст.
|
||||||
|
|||||||
@@ -132,3 +132,7 @@ Lock/readiness, существующее mm:servers:write, валидация п
|
|||||||
## 10.20. URL и завершение записи после history
|
## 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.
|
Device/tab сохранены в URL; invalid/missing context не расширяет срез. Unmounted child освобождает lock только живого родителя, который перечитывает каталог; source unmount игнорирует старый callback. 17 основных + 8 source E2E, два unit, TS/lint прошли. Далее search/object context/layout.
|
||||||
|
|
||||||
|
## 10.21. Поиск и узел
|
||||||
|
|
||||||
|
q/node сохраняются вместе с server/tab. Таблица и топология отражают один срез; пустой поиск не утверждает отсутствие конфигурации. 28 разных E2E, 3 unit, TS/lint и frontend production прошли. Accessible search/close; операция явно относится ко всем интерфейсам устройства. Далее metadata/layout/keyboard.
|
||||||
@@ -3,10 +3,18 @@ import { test } from "node:test"
|
|||||||
import { readOspfUrl, patchOspfUrl } from "./ospf-url-state"
|
import { readOspfUrl, patchOspfUrl } from "./ospf-url-state"
|
||||||
test("OSPF device/tab URL preserves unrelated context", () => {
|
test("OSPF device/tab URL preserves unrelated context", () => {
|
||||||
const url=patchOspfUrl(new URLSearchParams("keep=x"),{server:"2",tab:"bfd"})
|
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.deepEqual(readOspfUrl(new URL(url,"http://test").searchParams),{server:"2",tab:"bfd",search:"",node:null,error:null})
|
||||||
assert.equal(patchOspfUrl(new URL(url,"http://test").searchParams,{server:null,tab:null}),"/ospf?keep=x")
|
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",()=>{
|
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)
|
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)
|
assert.equal(readOspfUrl(new URLSearchParams("server=srv1&tab=neighbors")).error,null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("OSPF search/node roundtrip and invalid bounds",()=>{
|
||||||
|
const params=new URLSearchParams({server:"1",tab:"neighbors",q:"ether & 1",node:"remote-192.0.2.2",keep:"x"})
|
||||||
|
const state=readOspfUrl(params)
|
||||||
|
assert.equal(state.search,"ether & 1");assert.equal(state.node,"remote-192.0.2.2");assert.equal(state.error,null)
|
||||||
|
for(const raw of ["q=x&q=y","node=1&node=2","node=%2F","q="+"x".repeat(257),"node="+"x".repeat(129)])assert.ok(readOspfUrl(new URLSearchParams(raw)).error)
|
||||||
|
assert.equal(patchOspfUrl(params,{q:null,node:null}),"/ospf?server=1&tab=neighbors&keep=x")
|
||||||
|
})
|
||||||
@@ -2,12 +2,16 @@ export const ospfTabs = ["interfaces", "neighbors", "routes", "bfd"] as const
|
|||||||
export type OspfTab = typeof ospfTabs[number]
|
export type OspfTab = typeof ospfTabs[number]
|
||||||
export function readOspfUrl(params: URLSearchParams) {
|
export function readOspfUrl(params: URLSearchParams) {
|
||||||
const server = params.get("server") ?? "all"
|
const server = params.get("server") ?? "all"
|
||||||
|
const search = params.get("q") ?? ""
|
||||||
|
const node = params.get("node")
|
||||||
const rawTab = params.get("tab") ?? "interfaces"
|
const rawTab = params.get("tab") ?? "interfaces"
|
||||||
let error: string | null = null
|
let error: string | null = null
|
||||||
if (["server", "tab"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяется контекст OSPF."
|
if (["server", "tab", "q", "node"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяется контекст OSPF."
|
||||||
else if (!/^[a-zA-Z0-9_.:-]{1,64}$/.test(server)) error = "В ссылке указан некорректный сервер OSPF."
|
else if (!/^[a-zA-Z0-9_.:-]{1,64}$/.test(server)) error = "В ссылке указан некорректный сервер OSPF."
|
||||||
else if (!(ospfTabs as readonly string[]).includes(rawTab)) 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 }
|
else if (search.length > 256) error = "Поиск OSPF в ссылке слишком длинный."
|
||||||
|
else if (node !== null && !/^[a-zA-Z0-9_.:-]{1,128}$/.test(node)) error = "В ссылке указан некорректный узел OSPF."
|
||||||
|
return { server, search, node, tab: (ospfTabs as readonly string[]).includes(rawTab) ? rawTab as OspfTab : "interfaces" as OspfTab, error }
|
||||||
}
|
}
|
||||||
export function patchOspfUrl(params: URLSearchParams, changes: Record<string, string | null>) {
|
export function patchOspfUrl(params: URLSearchParams, changes: Record<string, string | null>) {
|
||||||
const next = new URLSearchParams(params)
|
const next = new URLSearchParams(params)
|
||||||
|
|||||||
@@ -36,3 +36,33 @@ for(const fails of [false,true])test("OSPF: history во время записи
|
|||||||
await expect(page.getByText("Старая ошибка",{exact:false})).toHaveCount(0)
|
await expect(page.getByText("Старая ошибка",{exact:false})).toHaveCount(0)
|
||||||
await expect(page.locator("[data-sonner-toast]").filter({hasText:"API подтвердил оптимизацию OSPF"})).toHaveCount(0)
|
await expect(page.locator("[data-sonner-toast]").filter({hasText:"API подтвердил оптимизацию OSPF"})).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("OSPF: поиск из URL не выдаёт пустой срез за отсутствие настройки",async({page})=>{
|
||||||
|
await setup(page);await page.goto("/ospf?server=2&q=ether2&keep=x")
|
||||||
|
await expect(page.getByPlaceholder("Поиск в текущем разделе OSPF…")).toHaveValue("ether2")
|
||||||
|
await expect(page.getByText("ether2",{exact:true})).toBeVisible();await expect(page.getByText("ether1",{exact:true})).toHaveCount(0)
|
||||||
|
await page.getByPlaceholder("Поиск в текущем разделе OSPF…").fill("несуществующий")
|
||||||
|
await expect(page.getByText("Нет интерфейсов по текущему поиску",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByText("OSPF интерфейсы не настроены ни на одном сервере",{exact:true})).toHaveCount(0)
|
||||||
|
await page.reload();await expect(page.getByText("Нет интерфейсов по текущему поиску",{exact:true})).toBeVisible()
|
||||||
|
await page.getByRole("button",{name:"Сбросить поиск",exact:true}).click()
|
||||||
|
await expect(page).toHaveURL(/server=2/);await expect(page).toHaveURL(/keep=x/);await expect(page.getByText("ether2",{exact:true})).toBeVisible()
|
||||||
|
})
|
||||||
|
test("OSPF: недоступный узел из URL исправляется без сброса устройства",async({page})=>{
|
||||||
|
await setup(page);await page.goto("/ospf?server=2&tab=neighbors&node=999&keep=x")
|
||||||
|
const alert=page.getByRole("alert").filter({hasText:"Узел OSPF недоступен"});await expect(alert).toBeVisible()
|
||||||
|
await alert.getByRole("button",{name:"Закрыть узел OSPF"}).click()
|
||||||
|
await expect(alert).toHaveCount(0);await expect(page).toHaveURL(/server=2/);await expect(page).toHaveURL(/keep=x/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("OSPF: выбранный узел и поиск сохраняются после reload/back",async({page})=>{
|
||||||
|
await setup(page)
|
||||||
|
await page.route("**/api/ospf/catalog",r=>r.fulfill({json:{...catalog,neighbors:[1,2].map(id=>({...host(id),id:"n",address:"198.51.100."+id,routerId:"198.51.100."+id,instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether"+id,state:"Full",uptime:"1h",priority:1,stateChanges:0}))}}))
|
||||||
|
await page.goto("/ospf?tab=neighbors&q=ether1&keep=x")
|
||||||
|
const rows=page.locator("tr[data-row-id]");await expect(rows).toHaveCount(1)
|
||||||
|
await rows.click();await expect(page).toHaveURL(/node=1/)
|
||||||
|
const details=page.getByRole("region",{name:"Детали узла OSPF"});await expect(details).toBeVisible()
|
||||||
|
await page.reload();await expect(details).toBeVisible();await expect(rows).toHaveCount(1)
|
||||||
|
await page.getByRole("button",{name:"Закрыть",exact:true}).click();await expect(details).toHaveCount(0)
|
||||||
|
await page.goBack();await expect(details).toBeVisible();await expect(page).toHaveURL(/keep=x/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user