From 3dc25628afdc34e56865f36b39809a759c9056b7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 18:02:11 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B8=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20OSPF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/ospf/page.tsx | 28 +++++++++---------- backend/src/routes/ospf.ts | 2 +- components/data-grids/ospf-bfd-data-grid.tsx | 6 ++-- .../data-grids/ospf-neighbors-data-grid.tsx | 8 ++++-- docs/ux-ui-progress.md | 9 +++++- docs/ux-ui-stage-10.md | 4 +++ lib/ospf-state.test.ts | 17 +++++++++++ lib/ospf-state.ts | 14 ++++++++++ tests/e2e/ospf-states.spec.ts | 16 +++++++++++ 9 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 lib/ospf-state.test.ts create mode 100644 lib/ospf-state.ts create mode 100644 tests/e2e/ospf-states.spec.ts diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 39b92565..3eebd016 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -1,5 +1,6 @@ "use client" +import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state" import { useMemo, useState, useEffect, useRef } from "react" import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" @@ -62,7 +63,7 @@ interface OspfNeighbor { remoteLabel: string remoteRouterId: string area: string - state: "Full" | "2-Way" | "ExStart" | "Down" + state: OspfNeighborState cost: number uptime: string priority: number @@ -88,7 +89,7 @@ interface BfdSession { serverLabel: string localAddr: string remoteAddr: string - state: "Up" | "Down" | "Init" | "AdminDown" + state: BfdState interval: number // txInterval ms multiplier: number iface: string @@ -139,8 +140,7 @@ function isRefInterfaceName(name: string): boolean { // ─── backend → frontend mappers ─────────────────────────────────────────────── function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map): OspfNeighbor { - const validStates = ["Full", "2-Way", "ExStart", "Down"] - const state = validStates.includes(b.state) ? b.state as OspfNeighbor["state"] : "Down" + const state = normalizeOspfState(b.state) const costKey = `${b.serverId}::${b.interface}` return { id: `${b.serverId}-${b.id}`, @@ -176,8 +176,7 @@ function backendToItem(b: BackendInterface): OspfItem { } function backendToBfdSession(b: BackendBfdSession): BfdSession { - const validStates = ["Up", "Down", "Init", "AdminDown"] - const state = validStates.includes(b.state) ? b.state as BfdSession["state"] : "Down" + const state = normalizeBfdState(b.state) return { id: `${b.serverId}-${b.id}`, serverId: String(b.serverId), @@ -283,6 +282,8 @@ const TOPO_H = 360 const NODE_R = 34 const NODE_THEME = { + Pending: { fill: "#2d1e06", stroke: "#fbbf24", glow: "transparent" }, + Unknown: { fill: "#111827", stroke: "#9ca3af", glow: "transparent" }, Full: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.18)" }, "2-Way":{ fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.14)" }, Down: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" }, @@ -290,6 +291,7 @@ const NODE_THEME = { } const EDGE_COLOR: Record = { + Attempt: "#fbbf24", Init: "#fbbf24", ExStart: "#fbbf24", Exchange: "#fbbf24", Loading: "#fbbf24", Unknown: "#9ca3af", Full: "#4ade80", "2-Way":"#fbbf24", Down: "#f87171", @@ -413,10 +415,7 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo function nodeState(id: string): keyof typeof NODE_THEME { const involved = graphEdges.filter(e => e.from === id || e.to === id) - if (!involved.length) return "none" - if (involved.some(e => e.state === "Full")) return "Full" - if (involved.some(e => e.state === "2-Way")) return "2-Way" - return "Down" + return ospfNodeState(involved.map(edge => edge.state)) } return ( @@ -568,10 +567,7 @@ function NodeDetailPanel({ const partCnt = adjacencies.filter(a => a.state === "2-Way").length // derive state from edges - let panelState: keyof typeof NODE_THEME = "none" - if (adjacencies.some(a => a.state === "Full")) panelState = "Full" - else if (adjacencies.some(a => a.state === "2-Way")) panelState = "2-Way" - else if (adjacencies.length) panelState = "Down" + const panelState = ospfNodeState(adjacencies.map(edge => edge.state)) const theme = NODE_THEME[panelState] const siteFill = @@ -926,6 +922,7 @@ function NeighborsTab({ const [selectedId, setSelectedId] = useState(null) const [showDots, setShowDots] = useState(true) + dataIncomplete = dataIncomplete || neighbors.some(n => n.state === "Unknown") const fullCount = neighbors.filter(n => n.state === "Full").length return ( @@ -1054,6 +1051,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) { // ─── BFD tab ────────────────────────────────────────────────────────────────── function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataIncomplete: 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 const initCount = sessions.filter(b => b.state === "Init").length @@ -1094,7 +1092,7 @@ function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataInco }, { id: "init", - label: "Init / другие", + label: "Init", value: dataIncomplete ? "—" : initCount, icon: , iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground", diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts index 0c99837e..c935ce01 100644 --- a/backend/src/routes/ospf.ts +++ b/backend/src/routes/ospf.ts @@ -554,7 +554,7 @@ const catalogDefaults: OspfCatalogDependencies = { readCatalog: async server => { const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await readOspfCatalogRaw(MikrotikClient.fromServer(server)) const areaMap = buildAreaMap(areas) - return { neighbors: parseNeighbors(server, neighbors, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseInstances(server, instances), bfdSessions: parseBfdSessions(server, bfdSessions), routes: parseOspfRoutes(server, ipRoutes) } + return { neighbors: parseNeighbors(server, neighbors, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseInstances(server, instances), bfdSessions: parseBfdSessions(server, bfdSessions).map((session, index) => ({ ...session, state: bfdSessions[index].state?.trim() ? session.state : "Unknown" })), routes: parseOspfRoutes(server, ipRoutes) } }, } export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalogDefaults): FastifyPluginAsyncZod { diff --git a/components/data-grids/ospf-bfd-data-grid.tsx b/components/data-grids/ospf-bfd-data-grid.tsx index 09a16df0..dec2aab2 100644 --- a/components/data-grids/ospf-bfd-data-grid.tsx +++ b/components/data-grids/ospf-bfd-data-grid.tsx @@ -7,6 +7,7 @@ import { type ColumnDef, useTable, } from "@tanstack/react-table" +import type { BfdState } from "@/lib/ospf-state" import { cn } from "@/lib/utils" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { @@ -25,7 +26,7 @@ export interface BfdSessionRow { serverLabel: string localAddr: string remoteAddr: string - state: "Up" | "Down" | "Init" | "AdminDown" + state: BfdState interval: number multiplier: number iface: string @@ -39,6 +40,7 @@ export interface BfdSessionRow { } function stateClass(state: BfdSessionRow["state"]) { + if (state === "Unknown") return "bg-muted text-muted-foreground border-border" if (state === "Up") return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25" if (state === "Init") @@ -149,7 +151,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) { row.original.dataUnknown ? "bg-muted text-muted-foreground" : stateClass(row.original.state), )} > - {row.original.dataUnknown ? "Неизвестно" : row.original.state} + {row.original.dataUnknown || row.original.state === "Unknown" ? "Неизвестно" : 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 c9c9d070..d95a8e1e 100644 --- a/components/data-grids/ospf-neighbors-data-grid.tsx +++ b/components/data-grids/ospf-neighbors-data-grid.tsx @@ -7,6 +7,7 @@ import { type ColumnDef, useTable, } from "@tanstack/react-table" +import type { OspfNeighborState } from "@/lib/ospf-state" import { cn } from "@/lib/utils" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { @@ -28,7 +29,7 @@ export interface OspfNeighborRow { remoteLabel: string remoteRouterId: string area: string - state: "Full" | "2-Way" | "ExStart" | "Down" + state: OspfNeighborState cost: number uptime: string priority: number @@ -37,7 +38,8 @@ export interface OspfNeighborRow { function stateClass(state: OspfNeighborRow["state"]) { if (state === "Full") return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25" - if (state === "2-Way") + if (state === "Unknown") return "bg-muted text-muted-foreground border-border" + if (state !== "Down") return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25" return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25" } @@ -133,7 +135,7 @@ function OspfNeighborsDataGrid({ row.original.dataUnknown ? "bg-muted text-muted-foreground" : stateClass(row.original.state), )} > - {row.original.dataUnknown ? "Неизвестно" : row.original.state} + {row.original.dataUnknown || row.original.state === "Unknown" ? "Неизвестно" : row.original.state} ), meta: { diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index b4ffee30..23a0a460 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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.12, свежесть OSPF; далее поля/URL/layout | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.13, состояния OSPF/BFD; далее неизвестные показатели/URL/layout | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.13 — подтверждённые состояния OSPF/BFD (2026-09-30) + +- Восемь состояний соседства RouterOS распознаются независимо от регистра. Attempt, Init, ExStart, Exchange и Loading сохраняются как переходные состояния; неизвестные значения показываются нейтрально, без подмены Down. Топология различает Pending/Unknown и подтверждённый отказ. При неизвестном состоянии сводка не выдаёт полный подсчёт состояний. +- Строгий /ospf/catalog сохраняет Unknown при отсутствии BFD state; legacy endpoints оставлены совместимыми. Правила оптимизации и сетевых операций не менялись. +- Проверки: 14 E2E (states/data/operations), два unit-теста агрегации топологии, TypeScript, production frontend (34 страницы) и backend build прошли. Lint: два прежних warning stateClass/fmtMs. +- Следующий блок: неизвестные cost/счётчики/таймеры OSPF и BFD (сейчас некоторые backend defaults ещё подменяют отсутствующие значения), затем URL-контекст и desktop/mobile приёмка. Этап 10 не завершён; реальные RouterOS/DB/JWT не проверены. Локальный коммит без push. + ## Блок 10.12 — время чтения и подтверждения OSPF (2026-09-30) - Отметки получения каталога, успешного опроса устройства и подтверждения оптимизации API разделены. Partial сохраняет время отказавшего устройства, общий отказ не обновляет успешные отметки. Удалённые/выключенные устройства исключаются из receipts; источник сбрасывает прежние времена. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index a6c0da75..4a9f3378 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -88,3 +88,7 @@ Lock/readiness, существующее mm:servers:write, валидация п ## Блок 10.12 — OSPF времена Время успешного опроса устройства сохраняется при отказах; подтверждение оптимизации API имеет отдельную отметку. Семь E2E/TypeScript/lint прошли. Далее — поля/URL/layout. + +## 10.13. Состояния соседства и BFD + +Все восемь состояний из [RouterOS OSPF](https://help.mikrotik.com/docs/spaces/ROS/pages/331612216/routing%2Bospf) сохранены. Переходные состояния — amber/Pending, неизвестные — neutral/Unknown, подтверждённый Down — red. Строгий каталог не подставляет Down при отсутствующем BFD state; старые endpoints совместимы. 14 E2E, два unit-теста, TS и обе production-сборки прошли. Далее неизвестные показатели, URL и визуальная приёмка. diff --git a/lib/ospf-state.test.ts b/lib/ospf-state.test.ts new file mode 100644 index 00000000..f65c7e06 --- /dev/null +++ b/lib/ospf-state.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { normalizeOspfState, normalizeBfdState, ospfNodeState } from "./ospf-state" +test("RouterOS states keep transitional adjacency separate from failure", () => { + assert.deepEqual(["full", "2-way", "ExStart", "exchange", "loading", "attempt", "init", "down"].map(normalizeOspfState), ["Full", "2-Way", "ExStart", "Exchange", "Loading", "Attempt", "Init", "Down"]) + assert.equal(normalizeOspfState("future-state"), "Unknown") + assert.equal(normalizeBfdState(""), "Unknown") + assert.equal(normalizeBfdState(" UP "), "Up") +}) +test("topology does not infer Down from missing or transitional evidence", () => { + assert.equal(ospfNodeState([]), "none") + assert.equal(ospfNodeState(["Down"]), "Down") + assert.equal(ospfNodeState(["Down", "Unknown"]), "Unknown") + assert.equal(ospfNodeState(["Exchange", "Loading"]), "Pending") + assert.equal(ospfNodeState(["2-Way", "Down"]), "2-Way") + assert.equal(ospfNodeState(["Full", "Unknown"]), "Full") +}) diff --git a/lib/ospf-state.ts b/lib/ospf-state.ts new file mode 100644 index 00000000..7d73cd82 --- /dev/null +++ b/lib/ospf-state.ts @@ -0,0 +1,14 @@ +export type OspfNeighborState = "Full" | "2-Way" | "ExStart" | "Down" | "Attempt" | "Init" | "Exchange" | "Loading" | "Unknown" +export type BfdState = "Up" | "Down" | "Init" | "AdminDown" | "Unknown" +const neighborStates: Record = { full: "Full", "2-way": "2-Way", exstart: "ExStart", down: "Down", attempt: "Attempt", init: "Init", exchange: "Exchange", loading: "Loading" } +const bfdStates: Record = { up: "Up", down: "Down", init: "Init", admindown: "AdminDown" } +export function normalizeOspfState(value: string): OspfNeighborState { return neighborStates[value.trim().toLowerCase()] ?? "Unknown" } +export function normalizeBfdState(value: string): BfdState { return bfdStates[value.trim().toLowerCase()] ?? "Unknown" } +export function ospfNodeState(states: OspfNeighborState[]): "Full" | "2-Way" | "Pending" | "Down" | "Unknown" | "none" { + if (!states.length) return "none" + if (states.includes("Full")) return "Full" + if (states.includes("Unknown")) return "Unknown" + if (states.includes("2-Way")) return "2-Way" + if (states.some(state => state !== "Down")) return "Pending" + return "Down" +} diff --git a/tests/e2e/ospf-states.spec.ts b/tests/e2e/ospf-states.spec.ts new file mode 100644 index 00000000..c669bcfb --- /dev/null +++ b/tests/e2e/ospf-states.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "@playwright/test" +test("OSPF и BFD: переходные и неизвестные состояния не становятся Down", async ({ page }) => { + const host = { serverId: 1, serverName: "Роутер", serverSite: "", serverCountry: "RU" } + const states = ["full", "2-way", "ExStart", "exchange", "loading", "attempt", "init", "down", "future-state"] + const catalog = { neighbors: states.map((state,i) => ({ ...host, id: String(i), address: "192.0.2."+i, routerId: "192.0.2."+i, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", state, uptime: "1h", stateChanges: 0, priority: 1 })), interfaces: [], instances: [], routes: [], failures: [], bfdSessions: ["up", "init", "down", "admindown", ""].map((state,i) => ({ ...host, id: String(i), localAddr: "192.0.2.1", remoteAddr: "198.51.100."+i, interface: "ether1", state, uptime: null, multihop: false, multiplier: 3, txInterval: 200, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 0, stateChanges: 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/ospf/catalog" ? catalog : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", country: "RU", enabled: true, status: null }] : 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(9) + for(const state of ["Full", "2-Way", "ExStart", "Exchange", "Loading", "Attempt", "Init", "Down", "Неизвестно"]) await expect(rows.getByText(state, { exact: true })).toHaveCount(1) + await page.getByRole("button", { name: "BFD", exact: true }).click() + await expect(rows).toHaveCount(5) + for(const state of ["Up", "Init", "Down", "AdminDown", "Неизвестно"]) await expect(rows.getByText(state, { exact: true })).toHaveCount(1) +})