fix(ui): сохранять переходные и неизвестные состояния OSPF
This commit is contained in:
1 parent
c18c148a23
commit
3dc25628af
9 files changed
+82
-22
No files matched your search
+13
-15
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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; источник сбрасывает прежние времена.
|
||||
|
||||
@@ -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 и визуальная приёмка.
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user