diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index d8b9a99c..5055c6a0 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -1,6 +1,8 @@ "use client" import { useMemo, useState, useEffect } from "react" +import { ospfCatalogSchema, ospfServersSchema, type OspfCatalog } from "@/lib/ospf-response" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { PageHeader } from "@/components/page-header" import { DataPageCard } from "@/components/data-page-card" import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid" @@ -12,7 +14,7 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { toast } from "sonner" import { - RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon, + RefreshCwIcon, WandSparklesIcon, GripVerticalIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon, LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon, } from "lucide-react" @@ -43,6 +45,7 @@ interface OspfItem { area: string cost: number active: boolean + dataUnknown?: boolean type?: string useBfd?: boolean helloInterval?: number @@ -62,6 +65,7 @@ interface OspfNeighbor { cost: number uptime: string priority: number + dataUnknown?: boolean } interface OspfRoute { @@ -74,6 +78,7 @@ interface OspfRoute { serverId: string serverLabel: string area: string + dataUnknown?: boolean } interface BfdSession { @@ -93,6 +98,7 @@ interface BfdSession { packetsRx: number packetsTx: number stateChanges: number + dataUnknown?: boolean } // ─── backend types ──────────────────────────────────────────────────────────── @@ -127,13 +133,6 @@ interface BackendOspfRoute { destination: string; type: OspfRoute["type"]; cost: number; nextHop: string; via: string; area: string } -interface BackendOspfAll { - neighbors: BackendNeighbor[] - interfaces: BackendInterface[] - instances: BackendInstance[] - bfdSessions: BackendBfdSession[] - routes?: BackendOspfRoute[] -} function isRefInterfaceName(name: string): boolean { return /^\(ref\s+\*.+\)$/.test(name.trim()) @@ -678,12 +677,16 @@ function InterfacesTab({ filterServerId, backendUrl, onLiveDataRefresh, + dataReady, + dataIncomplete, }: { items: OspfItem[] isLive: boolean filterServerId: string backendUrl: string onLiveDataRefresh: () => void + dataReady: boolean + dataIncomplete: boolean }) { const [items, setItems] = useState(initialItems) const [dragging, setDragging] = useState(null) @@ -732,24 +735,7 @@ function InterfacesTab({ }, [grouped, isLive]) const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost) - const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID - const uniqueLiveFallbackOpt = useMemo(() => { - const out: Record = {} - const byRouter: Record = {} - items.forEach((item) => { - if (!byRouter[item.routerKey]) byRouter[item.routerKey] = [] - byRouter[item.routerKey].push(item) - }) - Object.values(byRouter).forEach((routerItems) => { - routerItems - .slice() - .sort((a, b) => a.cost - b.cost || a.interfaceName.localeCompare(b.interfaceName)) - .forEach((item, idx) => { - out[item.key] = (idx + 1) * COST_STEP - }) - }) - return out - }, [items]) + const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID async function handleLiveOptimize() { if (!canOptimizeLive) { @@ -799,14 +785,14 @@ function InterfacesTab({ const h = hints[item.key] return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item })) - toast.success("Costs оптимизированы по рекомендациям оптимизатора") + toast.success("Локальный предпросмотр costs обновлён") } return (
{needsOptimize && ( )} - {!isLive && ( - - )} +
- {grouped.length === 0 && ( + {grouped.length === 0 && !dataIncomplete && (
OSPF интерфейсы не настроены ни на одном сервере
@@ -859,7 +841,7 @@ function InterfacesTab({ {ag.items.map(item => { const hint = hints[item.key] const matches = hint && hint.optimalCost === item.cost - const liveOptimal = liveOptimalCost[item.key] ?? uniqueLiveFallbackOpt[item.key] ?? item.cost + const liveOptimal = isLive ? liveOptimalCost[item.key] : hint?.optimalCost const costDiffers = liveOptimal !== item.cost return (
- + {item.interfaceName} + {item.dataUnknown && Неизвестно} {item.type && item.type !== "broadcast" && ( {item.type} )} @@ -891,10 +874,10 @@ function InterfacesTab({ }>opt {hint.optimalCost} )} cur {item.cost} - opt {liveOptimal} + }>opt {liveOptimal}}
) })} @@ -930,13 +913,15 @@ function InterfacesTab({ // ─── neighbors tab ──────────────────────────────────────────────────────────── function NeighborsTab({ - neighbors, items, graphNodes, graphEdges, routerIds, + neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown, }: { neighbors: OspfNeighbor[] items: OspfItem[] graphNodes: GraphNode[] graphEdges: GraphEdge[] routerIds: Record + dataIncomplete: boolean + graphUnknown: boolean }) { const [highlightId, setHighlightId] = useState(null) const [selectedId, setSelectedId] = useState(null) @@ -952,21 +937,21 @@ function NeighborsTab({ { id: "neighbors", label: "Всего соседей", - value: neighbors.length, + value: dataIncomplete ? "—" : neighbors.length, icon: , iconClassName: "text-muted-foreground", }, { id: "full", label: "Full", - value: fullCount, + value: dataIncomplete ? "—" : fullCount, icon: , iconClassName: "text-success", }, { id: "not-full", label: "Не Full", - value: neighbors.length - fullCount, + value: dataIncomplete ? "—" : neighbors.length - fullCount, icon: , iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground", variant: neighbors.length - fullCount > 0 ? "warning" : "default", @@ -974,7 +959,8 @@ function NeighborsTab({ ]} /> - {graphNodes.length > 0 && ( + {graphUnknown &&

Топология OSPF неизвестна до успешного опроса всех устройств.

} + {!graphUnknown && graphNodes.length > 0 && (
- {routes.length} маршрутов + {routes.some(r => r.dataUnknown) ? "Последний снимок: " : ""}{routes.length} маршрутов {typeCount.map(([type, count]) => ( {type}: {count} @@ -1068,7 +1054,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) { // ─── BFD tab ────────────────────────────────────────────────────────────────── -function BfdTab({ sessions }: { sessions: BfdSession[] }) { +function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataIncomplete: boolean }) { const upCount = sessions.filter(b => b.state === "Up").length const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").length const initCount = sessions.filter(b => b.state === "Init").length @@ -1088,21 +1074,21 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) { { id: "sessions", label: "Сессий BFD", - value: sessions.length, + value: dataIncomplete ? "—" : sessions.length, icon: , iconClassName: "text-muted-foreground", }, { id: "up", label: "Up", - value: upCount, + value: dataIncomplete ? "—" : upCount, icon: , iconClassName: "text-success", }, { id: "down", label: "Down / Admin", - value: downCount, + value: dataIncomplete ? "—" : downCount, icon: , iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground", variant: downCount > 0 ? "destructive" : "default", @@ -1110,7 +1096,7 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) { { id: "init", label: "Init / другие", - value: initCount, + value: dataIncomplete ? "—" : initCount, icon: , iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground", variant: initCount > 0 ? "warning" : "default", @@ -1135,7 +1121,7 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {

BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров. Tx / Rx — интервалы отправки и приёма контрольных пакетов. - {totalPkts > 0 && Всего получено: {totalPkts.toLocaleString()} пакетов.} + {!dataIncomplete && totalPkts > 0 && Всего получено: {totalPkts.toLocaleString()} пакетов.}

)} @@ -1153,14 +1139,19 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [ ] export default function OspfPage() { + const { mode, backendUrl } = useDataSource() + return +} +function OspfPageContent() { const [activeTab, setActiveTab] = useState("interfaces") const [filterServerId, setFilterServerId] = useState(ALL_SERVERS_ID) const { mode, backendUrl } = useDataSource() const isLive = mode === "live" - const [liveData, setLiveData] = useState(null) - const [loading, setLoading] = useState(false) + const [liveData, setLiveData] = useState(null) + const [liveServers, setLiveServers] = useState>([]) + const [loading, setLoading] = useState(isLive) const [fetchedAt, setFetchedAt] = useState(null) const [liveError, setLiveError] = useState(null) const [fetchTick, setFetchTick] = useState(0) @@ -1179,11 +1170,18 @@ export default function OspfPage() { queueMicrotask(() => { if (cancelled) return setLoading(true) - setLiveError(null) - void requestJson(backendUrl, "/api/ospf/all") - .then(data => { + void Promise.all([requestJson(backendUrl, "/api/ospf/catalog"), requestJson(backendUrl, "/api/servers")]) + .then(([response, servers]) => { if (cancelled) return - setLiveData(data); setFetchedAt(new Date()); setLoading(false) + const data = ospfCatalogSchema.parse(response) + const inventory = ospfServersSchema.parse(servers) + const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id)) + const sections = ["neighbors", "interfaces", "instances", "bfdSessions", "routes"] as const + if (sections.some(section => data[section].some(row => !enabled.has(row.serverId))) || data.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст OSPF-каталога") + const failed = new Set(data.failures.map(f => f.serverId)) + setLiveData(previous => ({ ...data, ...Object.fromEntries(sections.map(section => [section, [...data[section], ...(previous?.[section] ?? []).filter(row => enabled.has(row.serverId) && failed.has(row.serverId))]])) })) + setLiveServers(inventory.filter(s => s.enabled)) + setLiveError(null); setFetchedAt(new Date()); setLoading(false) }) .catch((err: unknown) => { if (cancelled) return @@ -1193,6 +1191,10 @@ export default function OspfPage() { return () => { cancelled = true } }, [isLive, backendUrl, fetchTick]) + const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData]) + const incomplete = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId)))) + const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !liveData.failures.length) + // Derive frontend types from backend data or use mocks const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => { if (isLive && liveData) { @@ -1203,11 +1205,11 @@ export default function OspfPage() { } const items = liveData.interfaces - .map(backendToItem) + .map(b => ({ ...backendToItem(b), dataUnknown: unknownIds.has(b.serverId) })) .filter((item) => !isRefInterfaceName(item.interfaceName)) - const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap)) - const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession) - const routes = (liveData.routes ?? []).map(backendToRoute) + const neighbors = liveData.neighbors.map(b => ({ ...backendToNeighbor(b, ifaceMap), dataUnknown: unknownIds.has(b.serverId) })) + const bfdSessions = liveData.bfdSessions.map(b => ({ ...backendToBfdSession(b), dataUnknown: unknownIds.has(b.serverId) })) + const routes = liveData.routes.map(b => ({ ...backendToRoute(b), dataUnknown: unknownIds.has(b.serverId) })) // Build routerIds from instances const routerIds: Record = {} @@ -1240,24 +1242,12 @@ export default function OspfPage() { bfdSessions: MOCK_BFD, routes: MOCK_ROUTES, } - }, [isLive, liveData]) + }, [isLive, liveData, unknownIds]) // ── server list for chips bar ──────────────────────────────────────────────── const ospfServers = useMemo((): OspfServerInfo[] => { if (isLive && liveData) { - const seen = new Map() - for (const inst of liveData.instances) { - const id = String(inst.serverId) - if (!seen.has(id)) - seen.set(id, { id, label: inst.serverName, site: inst.serverSite, country: inst.serverCountry }) - } - // Fallback: pick up servers that appear in neighbors/interfaces but not in instances - for (const n of liveData.neighbors) { - const id = String(n.serverId) - if (!seen.has(id)) - seen.set(id, { id, label: n.serverName, site: n.serverSite, country: n.serverCountry }) - } - return [...seen.values()] + return liveServers.map(s => ({ id: String(s.id), label: s.name, site: s.site ?? "", country: s.country })) } if (isLive) return [] // Mock: derive from MOCK_GRAPH_NODES that actually have interfaces @@ -1266,7 +1256,7 @@ export default function OspfPage() { return MOCK_GRAPH_NODES .filter(n => activeIds.has(n.id)) .map(n => ({ id: n.id, label: n.label, site: n.site, country: siteCountry[n.site] ?? "" })) - }, [isLive, liveData]) + }, [isLive, liveData, liveServers]) // Per-server counts for chips (all data, before filtering) const serverCounts = useMemo(() => { @@ -1294,12 +1284,12 @@ export default function OspfPage() { host, site: s.site, country: s.country || undefined, - meta: counts + meta: isLive && unknownIds.has(Number(s.id)) ? "Данные OSPF неизвестны" : counts ? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}` : undefined, } }) - ), [ospfServers, serverCounts]) + ), [ospfServers, serverCounts, isLive, unknownIds]) // Graph always shows full topology (highlight is handled by node click inside tab) // KPIs reflect the current filter @@ -1309,6 +1299,7 @@ export default function OspfPage() { return ( - @@ -1333,7 +1324,7 @@ export default function OspfPage() {
{TABS.map(t => ( -
)} - {isLive && !loading && fetchedAt && !liveError && ( -
- - - Живые данные · обновлено {fetchedAt.toLocaleTimeString("ru")} - - -
- )} - {isLive && liveError && ( -
- -

Ошибка загрузки: {liveError}

-
- )} + {isLive && fetchedAt &&

Ответ каталога получен . Время получения браузером, не измерения RouterOS.

} + {isLive && (liveError || Boolean(liveData?.failures.length)) && {liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}} + {incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) &&

Данные выбранного раздела OSPF пока неизвестны.

} {mode === "mock" && ( Моковые данные @@ -1385,47 +1361,52 @@ export default function OspfPage() { { id: "routers", label: "Роутеров", - value: totalRouters, + value: incomplete ? "—" : totalRouters, + hint: incomplete ? "неизвестно" : undefined, icon: , iconClassName: "text-muted-foreground", }, { id: "ifaces", label: "Интерфейсов", - value: totalInterfaces, + value: incomplete ? "—" : totalInterfaces, icon: , iconClassName: "text-info", }, { id: "areas", label: "Зон (Area)", - value: totalAreas, + value: incomplete ? "—" : totalAreas, icon: , iconClassName: "text-primary", }, ]} /> - {activeTab === "interfaces" && ( + {activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && ( setFetchTick(t => t + 1)} + dataReady={dataReady} + dataIncomplete={incomplete} /> )} - {activeTab === "neighbors" && ( + {activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && ( 0)} /> )} - {activeTab === "routes" && } - {activeTab === "bfd" && } + {activeTab === "routes" && (!incomplete || displayRoutes.length > 0) && } + {activeTab === "bfd" && (!incomplete || displayBfdSessions.length > 0) && }
diff --git a/components/data-grids/ospf-bfd-data-grid.tsx b/components/data-grids/ospf-bfd-data-grid.tsx index 600d4c62..09a16df0 100644 --- a/components/data-grids/ospf-bfd-data-grid.tsx +++ b/components/data-grids/ospf-bfd-data-grid.tsx @@ -20,6 +20,7 @@ import { ActivityIcon } from "lucide-react" export interface BfdSessionRow { id: string + dataUnknown?: boolean serverId: string serverLabel: string localAddr: string @@ -145,10 +146,10 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) { - {row.original.state} + {row.original.dataUnknown ? "Неизвестно" : row.original.state} ), meta: { diff --git a/components/data-grids/ospf-neighbors-data-grid.tsx b/components/data-grids/ospf-neighbors-data-grid.tsx index 30dd5990..c9c9d070 100644 --- a/components/data-grids/ospf-neighbors-data-grid.tsx +++ b/components/data-grids/ospf-neighbors-data-grid.tsx @@ -20,6 +20,7 @@ import { NetworkIcon } from "lucide-react" export interface OspfNeighborRow { id: string + dataUnknown?: boolean localRouter: string localLabel: string localIface: string @@ -129,10 +130,10 @@ function OspfNeighborsDataGrid({ - {row.original.state} + {row.original.dataUnknown ? "Неизвестно" : row.original.state} ), meta: { diff --git a/components/data-grids/ospf-routes-data-grid.tsx b/components/data-grids/ospf-routes-data-grid.tsx index 8593367b..bcffa15c 100644 --- a/components/data-grids/ospf-routes-data-grid.tsx +++ b/components/data-grids/ospf-routes-data-grid.tsx @@ -20,6 +20,7 @@ import { RouteIcon } from "lucide-react" export interface OspfRouteRow { id: string + dataUnknown?: boolean destination: string type: "O" | "O IA" | "O E1" | "O E2" cost: number @@ -116,7 +117,7 @@ function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) { id: "serverLabel", accessorKey: "serverLabel", header: ({ column }) => , - cell: ({ row }) => {row.original.serverLabel}, + cell: ({ row }) => {row.original.serverLabel}{row.original.dataUnknown ? " · Неизвестно" : ""}, meta: { headerTitle: "Роутер", headerClassName: DATA_GRID_CELL_PAD, diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f1617697..d09ad0a5 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,19 @@ | 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.9, строгий каталог OSPF; далее экран | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.10, данные OSPF; далее операции | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.10 — достоверность данных OSPF (2026-09-30) + +- Экран использует /ospf/catalog и inventory, проверяет все пять списков/поля/ID/failures и включённый контекст. Partial сохраняет набор отказавшего устройства, successful empty очищает; общий/ошибочный ответ сохраняет кэш. Mode/backendUrl изолированы key/cleanup. +- Начальный отказ не выглядит как пустая настройка/нули. KPI неизвестны, строки neighbors/BFD/interfaces/routes явно отмечены при сбое. Топология не считается текущей при неполном опросе; сводка исправного выбранного устройства остаётся доступной. Rail включает включённые устройства без OSPF-строк. +- Постоянный alert/повтор и отметка браузерного получения; повтор Refresh блокируется во время чтения. Оптимизация пока доступна только после полного успешного каталога. Времена успешного опроса каждого устройства, права/lock/поздний write ещё требуют следующего блока. +- Удалён придуманный live optimal cost из порядка интерфейсов; opt показан только после ответа оптимизации. Убрано ложное mock «применены»: локальное изменение обозначено предпросмотром; кнопка сброса не выдаётся за серверный опрос. +- 17 E2E прошли (семь OSPF data + routing/BGP), TypeScript чистый, lint без ошибок с двумя исходными warning stateClass/fmtMs. Далее — операции OSPF, URL/свежесть, unknown state/counters и layout. Квота: 84% окна использовано; локальный коммит без push. + ## Блок 10.9 — строгий каталог OSPF (2026-09-30) - Добавлен /ospf/catalog с пятью списками и failures по устройствам. Строгий reader не скрывает отказ BFD или /ip/route за []; успешный пустой ответ остаётся пустым. Чужой serverId, пустые/повторяющиеся ID в разделе отклоняются. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 3f4c877b..83c03a54 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -76,3 +76,7 @@ server/session/tab и возврат/reload сохраняют контекст; ## Блок 10.9 — OSPF каталог Строгий /ospf/catalog возвращает failures устройства; BFD/routes ошибки не скрываются. Снимок устройства атомарен, legacy /all и правила оптимизации сохранены. Unit/HTTP/backend build прошли; два исходных lint warning. Далее — экран OSPF. + +## Блок 10.10 — OSPF данные + +Каталог/inventory валидируются, кэш отказавшего устройства сохраняется, состояния/KPI неизвестны, топология не подменяет полный опрос. Убраны фиктивные live costs/mock применение. 17 E2E/TypeScript/lint прошли (два старых warning). Далее — операции/URL/свежесть/поля/layout. diff --git a/lib/ospf-response.ts b/lib/ospf-response.ts new file mode 100644 index 00000000..3dcc043d --- /dev/null +++ b/lib/ospf-response.ts @@ -0,0 +1,17 @@ +import { z } from "zod" +import { vxlanServersSchema } from "@/lib/vxlan-response" +const counter = z.number().int().nonnegative() +const base = z.object({ id: z.string().trim().min(1), serverId: z.number().int().positive(), serverName: z.string(), serverSite: z.string(), serverCountry: z.string().default("UN") }) +export const ospfCatalogSchema = z.object({ + neighbors: z.array(base.extend({ address: z.string(), routerId: z.string(), instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), stateChanges: counter, priority: counter })), + interfaces: z.array(base.extend({ instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), cost: counter, type: z.string(), disabled: z.boolean(), inactive: z.boolean(), priority: counter, helloInterval: counter, deadInterval: counter, useBfd: z.boolean() })), + instances: z.array(base.extend({ name: z.string(), routerId: z.string(), version: counter, disabled: z.boolean(), inactive: z.boolean(), redistribute: z.string() })), + bfdSessions: z.array(base.extend({ localAddr: z.string(), remoteAddr: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), multihop: z.boolean(), multiplier: counter, txInterval: counter, rxInterval: counter, holdTime: counter, packetsRx: counter, packetsTx: counter, stateChanges: counter })), + routes: z.array(base.extend({ destination: z.string(), type: z.enum(["O", "O IA", "O E1", "O E2"]), cost: counter, nextHop: z.string(), via: z.string(), area: z.string() })), + failures: z.array(z.object({ serverId: z.number().int().positive(), serverName: z.string(), error: z.string() })), +}).refine(data => { + const failed = new Set(data.failures.map(f => f.serverId)) + return failed.size === data.failures.length && [data.neighbors, data.interfaces, data.instances, data.bfdSessions, data.routes].every(rows => new Set(rows.map(row => JSON.stringify([row.serverId, row.id]))).size === rows.length && rows.every(row => !failed.has(row.serverId))) +}) +export const ospfServersSchema = vxlanServersSchema +export type OspfCatalog = z.infer diff --git a/tests/e2e/ospf-data.spec.ts b/tests/e2e/ospf-data.spec.ts new file mode 100644 index 00000000..838cb5bf --- /dev/null +++ b/tests/e2e/ospf-data.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from "@playwright/test" +const servers = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, country: "RU", enabled: true, status: null })) +const host = (serverId: number) => ({ serverId, serverName: "Роутер " + serverId, serverSite: "", serverCountry: "RU" }) +const neighbor = (serverId: number, address = "192.0.2." + serverId) => ({ ...host(serverId), id: "*1", address, routerId: address, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", state: "Full", uptime: "1h", stateChanges: 0, priority: 1 }) +const all = { neighbors: [neighbor(1), neighbor(2)], interfaces: [], instances: [], bfdSessions: [], routes: [], failures: [] } + +for (const broken of ["http", "payload", "duplicate", "conflict", "foreign", "inventory"]) { + test("OSPF: неизвестное начало, кэш и восстановление " + broken, async ({ page }) => { + let phase = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/servers") return r.fulfill({ json: phase === 2 && broken === "inventory" ? [servers[0], servers[0]] : servers }) + if (path === "/api/ospf/catalog") { + if (phase === 0 || phase === 2 && broken === "http") return r.fulfill({ status: 503, json: { error: "OSPF недоступен тест" } }) + return r.fulfill({ json: phase !== 2 ? all : broken === "payload" ? { ...all, neighbors: [{}] } + : broken === "duplicate" ? { ...all, neighbors: [neighbor(1), neighbor(1)] } + : broken === "conflict" ? { ...all, failures: [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут" }] } + : broken === "foreign" ? { ...all, neighbors: [neighbor(3)] } : all }) + } + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ospf") + const alert = page.getByRole("alert").filter({ hasText: "Не удалось загрузить OSPF" }) + await expect(alert).toContainText("Данные пока неизвестны") + await expect(page.getByLabel("Сводка OSPF", { exact: true })).toContainText("неизвестно") + await expect(page.getByText("OSPF интерфейсы не настроены ни на одном сервере", { exact: true })).toHaveCount(0) + phase = 1 + await alert.getByRole("button", { name: "Повторить загрузку OSPF" }).click() + await page.getByRole("button", { name: "Соседи", exact: true }).click() + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(2) + phase = 2 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(alert).toContainText("Показаны последние полученные данные") + await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(2) + await expect(page.getByRole("status").filter({ hasText: "Топология OSPF неизвестна" })).toBeVisible() + phase = 3 + await alert.getByRole("button", { name: "Повторить загрузку OSPF" }).click() + await expect(alert).toHaveCount(0) + await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(0) + }) +} +test("OSPF: частичный отказ сохраняет устройство, успешный пустой опрос очищает", async ({ page }) => { + let phase = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + return r.fulfill({ json: path === "/api/servers" ? servers : path === "/api/ospf/catalog" ? phase === 0 ? all : { ...all, neighbors: [neighbor(1, "198.51.100.1")], failures: phase === 1 ? [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут BFD тест" }] : [] } : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ospf") + await page.getByRole("button", { name: "Соседи", exact: true }).click() + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(2) + phase = 1 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + const alert = page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров OSPF" }) + await expect(alert).toContainText("Роутер 2: Таймаут BFD тест") + await expect(rows.filter({ hasText: "Роутер 2" })).toContainText("Неизвестно") + await expect(rows.filter({ hasText: "198.51.100.1" })).toContainText("Full") + await page.getByRole("option").filter({ hasText: "Роутер 1" }).click() + await expect(page.getByLabel("Сводка OSPF", { exact: true })).not.toContainText("неизвестно") + await page.getByRole("option").filter({ hasText: "Роутер 2" }).click() + await expect(page.getByLabel("Сводка OSPF", { exact: true })).toContainText("неизвестно") + phase = 2 + await alert.getByRole("button", { name: "Повторить загрузку OSPF" }).click() + await expect(rows).toHaveCount(0) + await expect(page.getByText("Нет OSPF-соседей", { exact: true })).toBeVisible() +}) diff --git a/tests/e2e/routing-tables.spec.ts b/tests/e2e/routing-tables.spec.ts index a0a56e9c..ba7574d7 100644 --- a/tests/e2e/routing-tables.spec.ts +++ b/tests/e2e/routing-tables.spec.ts @@ -22,10 +22,10 @@ async function setup(page: Page, path: string, empty = false) { await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/api/**", route => { const path = new URL(route.request().url()).pathname - const json = path === "/api/ospf/all" ? (empty ? { instances: [], interfaces: [], neighbors: [], routes: [], bfdSessions: [] } : ospf) + const json = path === "/api/ospf/catalog" ? (empty ? { instances: [], interfaces: [], neighbors: [], routes: [], bfdSessions: [], failures: [] } : { ...ospf, failures: [] }) : path === "/api/bgp/catalog" ? { sessions: empty ? [] : bgp, failures: [] } : path === "/api/auth/config" ? { required: false } - : path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.100", country: "RU", enabled: true, status: null }] : {} + : path === "/api/servers" ? ids.map(id => ({ id, name: "Тестовый роутер " + id, host: "192.0.2." + id, country: "RU", enabled: true, status: null })) : {} return route.fulfill({ json }) }) await page.goto(path)