fix(ui): сохранять переходные и неизвестные состояния OSPF

This commit is contained in:
Denozordec committed 2026-09-30 18:02:11 +07:00
1 parent c18c148a23
commit 3dc25628af
9 files changed
+82 -22

No files matched your search

+13 -15
View File
@@ -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<string, number>): 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<string, string> = {
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<string | null>(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: <LayersIcon className="size-4" />,
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
+1 -1
View File
@@ -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 {
+4 -2
View File
@@ -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}
</span>
),
meta: {
@@ -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}
</span>
),
meta: {
+8 -1
View File
@@ -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; источник сбрасывает прежние времена.
+4
View File
@@ -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 и визуальная приёмка.
+17
View File
@@ -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")
})
+14
View File
@@ -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<string, OspfNeighborState> = { full: "Full", "2-way": "2-Way", exstart: "ExStart", down: "Down", attempt: "Attempt", init: "Init", exchange: "Exchange", loading: "Loading" }
const bfdStates: Record<string, BfdState> = { 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"
}
+16
View File
@@ -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)
})