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>
|
||||
|
||||
@@ -45,6 +45,7 @@ function stateClass(state: OspfNeighborRow["state"]) {
|
||||
}
|
||||
|
||||
interface OspfNeighborsDataGridProps {
|
||||
emptyTitle?: string
|
||||
neighbors: OspfNeighborRow[]
|
||||
highlightRouterId?: string | null
|
||||
selectedRouterId?: string | null
|
||||
@@ -54,6 +55,7 @@ interface OspfNeighborsDataGridProps {
|
||||
|
||||
function OspfNeighborsDataGrid({
|
||||
neighbors,
|
||||
emptyTitle,
|
||||
highlightRouterId,
|
||||
selectedRouterId,
|
||||
onSelect,
|
||||
@@ -211,7 +213,7 @@ function OspfNeighborsDataGrid({
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет OSPF-соседей"
|
||||
title={emptyTitle ?? "Нет OSPF-соседей"}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -39,10 +39,11 @@ function routeTypeClass(type: OspfRouteRow["type"]) {
|
||||
}
|
||||
|
||||
interface OspfRoutesDataGridProps {
|
||||
emptyTitle?: string
|
||||
routes: OspfRouteRow[]
|
||||
}
|
||||
|
||||
function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
|
||||
function OspfRoutesDataGrid({ routes, emptyTitle }: OspfRoutesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, OspfRouteRow>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -153,7 +154,7 @@ function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<RouteIcon className="size-4" />}
|
||||
title="Нет OSPF-маршрутов"
|
||||
title={emptyTitle ?? "Нет OSPF-маршрутов"}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ interface DataPageToolbarProps<T extends string = string> {
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
searchLabel?: string
|
||||
segmented?: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
@@ -46,6 +47,7 @@ function DataPageToolbar<T extends string = string>({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Поиск…",
|
||||
searchLabel,
|
||||
segmented,
|
||||
filterControl,
|
||||
countLabel,
|
||||
@@ -69,6 +71,7 @@ function DataPageToolbar<T extends string = string>({
|
||||
<SearchIcon className="size-3.5" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label={searchLabel ?? searchPlaceholder}
|
||||
placeholder={searchPlaceholder}
|
||||
value={search ?? ""}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
|
||||
@@ -26,11 +26,18 @@
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 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Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 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)
|
||||
|
||||
- Устройство/вкладка контролируются URL; reload/back восстанавливают выбор, другие параметры сохранены. Повторные/невалидные параметры и отсутствующее устройство дают явный alert, не раскрывают общий каталог и не показывают нулевую сводку; reset исправляет только этот контекст.
|
||||
|
||||
@@ -132,3 +132,7 @@ Lock/readiness, существующее mm:servers:write, валидация п
|
||||
## 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.
|
||||
|
||||
## 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"
|
||||
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.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")
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
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 function readOspfUrl(params: URLSearchParams) {
|
||||
const server = params.get("server") ?? "all"
|
||||
const search = params.get("q") ?? ""
|
||||
const node = params.get("node")
|
||||
const rawTab = params.get("tab") ?? "interfaces"
|
||||
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 (!(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>) {
|
||||
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.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