diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index 6eb02d6e..788337c2 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -11,6 +11,7 @@ import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query" import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields" import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types" import { bgpCatalogSchema, bgpServersSchema, type BackendBgpSession, type BgpFailure } from "@/lib/bgp/response" +import { fmtBgpNum, sumBgpCounters } from "@/lib/bgp/helpers" import { BGP_AS_NAMES } from "@/lib/bgp/types" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" @@ -210,11 +211,7 @@ function TypeBadge({ type }: { type: BgpType }) { } -function fmtNum(n: number) { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` - if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k` - return String(n) -} +const fmtNum = fmtBgpNum // ─── backend mapping ────────────────────────────────────────────────────────── @@ -239,13 +236,13 @@ function backendToFrontend(b: BackendBgpSession): BgpSession { description: b.name, state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown", type: b.type, - afi: "IPv4 Unicast", + afi: b.afi ?? "Неизвестно", uptime: b.uptime, holdTime: b.holdTime, keepalive: b.keepalive, prefixesRx: b.prefixesRx, prefixesTx: b.prefixesTx, - prefixesActive: b.prefixesRx, + prefixesActive: null, inputMessages: b.inputMessages, outputMessages: b.outputMessages, capabilities: b.capabilities, @@ -349,8 +346,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
{byRouter.map(router => { const estCnt = router.sessions.filter(s => s.state === "Established").length - const downCnt = router.sessions.length - estCnt - const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0) + const downCnt = router.sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length + const totalRx = sumBgpCounters(router.sessions.map(s => s.prefixesRx)) return ( @@ -360,7 +357,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {

{router.label}

-

{router.site} · AS65001

+

{router.site} · {Array.from(new Set(router.sessions.map(s => "AS" + s.localAs))).join(", ")}

{estCnt > 0 && ( @@ -398,12 +395,12 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
- {s.prefixesRx > 0 && ( + {s.prefixesRx !== null && s.prefixesRx > 0 && (

↓ {fmtNum(s.prefixesRx)}

)} -

{s.state}

+

{ss.label}

) @@ -411,7 +408,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) { {/* footer summary */} - {totalRx > 0 && ( + {totalRx !== null && totalRx > 0 && (
@@ -432,9 +429,9 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) { function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { // top peers by prefixes received (eBGP only) const topPeers = useMemo(() => - [...sessions] - .filter(s => s.prefixesRx > 0) - .sort((a, b) => b.prefixesRx - a.prefixesRx) + (sessions.some(s => s.prefixesRx === null) ? [] : [...sessions]) + .filter(s => s.prefixesRx !== null && s.prefixesRx > 0) + .sort((a, b) => (b.prefixesRx ?? 0) - (a.prefixesRx ?? 0)) .slice(0, 8), [sessions] ) @@ -448,8 +445,8 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { }, [sessions]) // total prefix stats - const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0) - const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0) + const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx)) + const totalActive = sumBgpCounters(sessions.map(s => s.prefixesActive)) const ebgpSessions = sessions.filter(s => s.type === "eBGP").length const ibgpSessions = sessions.filter(s => s.type === "iBGP").length @@ -468,6 +465,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { { id: "active", label: "Активных маршрутов", + hint: totalActive === null ? "нет данных API" : undefined, value: fmtNum(totalActive), icon: , iconClassName: "text-info", @@ -494,7 +492,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{topPeers.map((s, i) => { - const pct = (s.prefixesRx / maxRx) * 100 + const pct = ((s.prefixesRx ?? 0) / maxRx) * 100 const ss = STATE_STYLE[s.state] return (
@@ -524,7 +522,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { ) })} {topPeers.length === 0 && ( -

Нет данных о префиксах

+

Недостаточно данных о префиксах для сравнения

)}
@@ -543,7 +541,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { return (
- {state} + {ss.label}
@@ -707,8 +705,8 @@ function BgpPageContent() { }, [displayServers, allSessions, hasLoaded, isLive, unknownIds]) const established = sessions.filter(s => s.state === "Established").length - const notEstab = sessions.length - established - const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0) + const notEstab = sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length + const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx)) return ( @@ -796,14 +794,14 @@ function BgpPageContent() { { id: "established", label: "Established", - value: incomplete ? "—" : established, + value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established, icon: , iconClassName: "text-success", }, { id: "not-estab", label: "Не установлено", - value: incomplete ? "—" : notEstab, + value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab, icon: , iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground", variant: notEstab > 0 ? "warning" : "default", @@ -826,7 +824,7 @@ function BgpPageContent() { {notEstab} сессии не в состоянии Established - Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")} + Проверьте {sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").map(s => s.peerIp).join(", ")} )} diff --git a/backend/package.json b/backend/package.json index 2aef6b12..d76a9ec8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,7 +22,7 @@ "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts", - "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts", + "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts", "test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts" }, diff --git a/backend/src/routes/bgp.ts b/backend/src/routes/bgp.ts index 07a7dd92..a15eeaae 100644 --- a/backend/src/routes/bgp.ts +++ b/backend/src/routes/bgp.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { db } from "../db/index.js" import { servers } from "../db/schema.js" -import { parseBgpSessions } from "../services/bgp-parse-sessions.js" +import { parseBgpSessions, parseBgpCatalogSessions, type BgpCatalogSessionRead } from "../services/bgp-parse-sessions.js" import { MikrotikClient } from "../services/mikrotik.js" import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" import type { BgpSessionRead } from "../types/server.js" @@ -13,11 +13,13 @@ type ServerRow = typeof servers.$inferSelect interface BgpDependencies { listEnabledServers: () => Promise findServer: (id: number) => Promise + readCatalogSessions?: (server: ServerRow) => Promise readSessions: (server: ServerRow) => Promise } const defaults: BgpDependencies = { listEnabledServers: () => db.select().from(servers).where(eq(servers.enabled, true)), findServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0], + readCatalogSessions: async server => parseBgpCatalogSessions(server, await MikrotikClient.fromServer(server).getBgpSessions()), readSessions: async server => parseBgpSessions(server, await MikrotikClient.fromServer(server).getBgpSessions()), } export function createBgpRoutes(dependencies: BgpDependencies = defaults): FastifyPluginAsyncZod { @@ -25,7 +27,7 @@ export function createBgpRoutes(dependencies: BgpDependencies = defaults): Fasti // Отдельный каталог сохраняет совместимость старого массива для оповещений. app.get("/bgp/catalog", async (_req, reply) => { const devices = await dependencies.listEnabledServers() - return reply.send(await pollBgpCatalog(devices, dependencies.readSessions)) + return reply.send(await pollBgpCatalog(devices, dependencies.readCatalogSessions ?? dependencies.readSessions)) }) // GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug) diff --git a/backend/src/services/bgp-catalog-parse.test.ts b/backend/src/services/bgp-catalog-parse.test.ts new file mode 100644 index 00000000..481909a2 --- /dev/null +++ b/backend/src/services/bgp-catalog-parse.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict" +import { parseBgpCatalogSessions, parseBgpSessions } from "./bgp-parse-sessions.js" +import { servers } from "../db/schema.js" +const server = { id: 1, name: "Тест", host: "192.0.2.1", site: "", country: "RU" } as typeof servers.$inferSelect +const raw = { ".id": "*1", "total-updates-received": "42", "total-updates-sent": "99" } +const missing = parseBgpCatalogSessions(server, [raw])[0] +assert.equal(missing.state, "Unknown") +for (const field of ["prefixesRx", "prefixesTx", "holdTime", "keepalive", "inputMessages", "outputMessages", "afi"] as const) assert.equal(missing[field], null) +assert.equal(parseBgpSessions(server, [raw])[0].prefixesRx, 42) +const observed = parseBgpCatalogSessions(server, [{ ".id": "*2", established: "true", "prefix-count": "0", "hold-time": "0", "keepalive-time": "1m30s", "remote.messages": "0", "local.messages": "2", "remote.afi": "ip,ipv6" }])[0] +assert.equal(observed.state, "Established") +assert.equal(observed.prefixesRx, 0) +assert.equal(observed.holdTime, 0) +assert.equal(observed.keepalive, 90) +assert.equal(observed.inputMessages, 0) +assert.equal(observed.afi, "ip,ipv6") +const invalid = parseBgpCatalogSessions(server, [{ ".id": "*3", "prefix-count": "12bad", "hold-time": "invalid", "remote.messages": "-1" }])[0] +assert.equal(invalid.prefixesRx, null) +assert.equal(invalid.holdTime, null) +assert.equal(invalid.inputMessages, null) +console.log("BGP parser: unknown/observed zero/timers/AFI, no UPDATE-prefix substitution, legacy preserved") diff --git a/backend/src/services/bgp-parse-sessions.ts b/backend/src/services/bgp-parse-sessions.ts index 7dce6ca9..8407aa24 100644 --- a/backend/src/services/bgp-parse-sessions.ts +++ b/backend/src/services/bgp-parse-sessions.ts @@ -135,3 +135,33 @@ export function parseBgpSessions(server: ServerRow, raw: RosBgpSession[]): BgpSe } }) } + +/** Каталог не заменяет отсутствующие показатели значениями по умолчанию старого API. */ +export type BgpCatalogSessionRead = Omit & { + holdTime: number | null; keepalive: number | null; prefixesRx: number | null; prefixesTx: number | null; + inputMessages: number | null; outputMessages: number | null; afi: string | null; +} +function readCounter(value: string | undefined): number | null { + if (value === undefined || !/^\d+$/.test(value.trim())) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? parsed : null +} +function readTimer(value: string | undefined): number | null { + if (value === "0" || value === "infinity") return 0 + if (!value || !/^(?:\d+(?:w|d|h|ms|m|s))+$/.test(value)) return null + return parseDuration(value) +} +export function parseBgpCatalogSessions(server: ServerRow, raw: RosBgpSession[]): BgpCatalogSessionRead[] { + return parseBgpSessions(server, raw).map((session, index) => { + const s = raw[index] + return { ...session, + state: s.established === "true" ? "Established" : s.state?.trim() ? detectState(s) : "Unknown", + holdTime: readTimer(s["hold-time"]) ?? readCounter(s["active-holdtime"]), + keepalive: readTimer(s["keepalive-time"]), + prefixesRx: readCounter(s["prefix-count"]), prefixesTx: null, + inputMessages: readCounter(s["remote.messages"] ?? s["total-messages-received"]), + outputMessages: readCounter(s["local.messages"] ?? s["total-messages-sent"]), + afi: s["remote.afi"]?.trim() || null, + } + }) +} diff --git a/components/data-grids/bgp-session-detail.tsx b/components/data-grids/bgp-session-detail.tsx index fae1857a..ce60b8d4 100644 --- a/components/data-grids/bgp-session-detail.tsx +++ b/components/data-grids/bgp-session-detail.tsx @@ -1,130 +1,24 @@ "use client" - -import { useState } from "react" -import { cn } from "@/lib/utils" -import { ClipboardCopyIcon } from "lucide-react" import type { BgpSessionRow } from "@/lib/bgp/types" -import { fmtBgpNum, rscBgpSnippet } from "@/lib/bgp/helpers" - -function CapChip({ cap }: { cap: string }) { - return ( - - {cap} - - ) -} - -function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) { - const max = Math.max(rx, 1) - return ( -
- {[ - { label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max }, - { label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max }, - { label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) }, - ].map((r) => ( -
- {r.label} -
-
0 ? 2 : 0)}%` }} - /> -
- {fmtBgpNum(r.val)} -
- ))} -
- ) -} +import { fmtBgpNum } from "@/lib/bgp/helpers" function BgpSessionDetail({ session }: { session: BgpSessionRow }) { - const [copied, setCopied] = useState(false) - - function copy() { - navigator.clipboard.writeText(rscBgpSnippet(session)).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 1800) - }) - } - - return ( -
-
- {[ - { label: "Router ID", value: session.routerId }, - { label: "Hold / KA", value: `${session.holdTime}s / ${session.keepalive}s` }, - { label: "AFI/SAFI", value: session.afi }, - { - label: "Сообщения ↓/↑", - value: `${fmtBgpNum(session.inputMessages)} / ${fmtBgpNum(session.outputMessages)}`, - }, - ].map(({ label, value }) => ( -
-

{label}

-

{value}

-
- ))} -
- - {session.state === "Established" && ( -
-

- Префиксы -

- -
- )} - - {session.capabilities.length > 0 && ( -
-

- Capabilities -

-
- {session.capabilities.map((c) => ( - - ))} -
-
- )} - - {session.lastError && ( -
- -

{session.lastError}

-
- )} - -
-

- RouterOS Export -

-
-
-            {rscBgpSnippet(session)}
-          
- -
-
+ return
+ {session.state === "Unknown" &&

Состояние сессии неизвестно. Значения относятся к последнему полученному снимку.

} +
+ {[ + { label: "Router ID", value: session.routerId || "—" }, + { label: "Hold / KA", value: (session.holdTime === null ? "—" : session.holdTime + "s") + " / " + (session.keepalive === null ? "—" : session.keepalive + "s") }, + { label: "AFI/SAFI", value: session.afi }, + { label: "Сообщения ↓/↑", value: fmtBgpNum(session.inputMessages) + " / " + fmtBgpNum(session.outputMessages) }, + { label: "Префиксов получено", value: fmtBgpNum(session.prefixesRx) }, + { label: "Префиксов отправлено", value: fmtBgpNum(session.prefixesTx) }, + { label: "Активных маршрутов", value: session.prefixesActive === null ? "Нет данных API" : fmtBgpNum(session.prefixesActive) }, + ].map(({ label, value }) =>

{label}

{value}

)}
- ) + {session.capabilities.length > 0 &&

Capabilities

{session.capabilities.map((cap, i) => {cap})}
} + {session.lastError &&

{session.lastError}

} +

API возвращает состояние сессии. Конфигурация connection, routing-table и фильтров для экспорта здесь не получена.

+
} - export { BgpSessionDetail } diff --git a/components/data-grids/bgp-sessions-data-grid.tsx b/components/data-grids/bgp-sessions-data-grid.tsx index 3f87fd34..1f8b06aa 100644 --- a/components/data-grids/bgp-sessions-data-grid.tsx +++ b/components/data-grids/bgp-sessions-data-grid.tsx @@ -223,7 +223,7 @@ function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps) header: ({ column }) => , cell: ({ row }) => { const n = row.original.prefixesRx - return n > 0 ? ( + return n !== null ? ( {fmtBgpNum(n)} @@ -243,7 +243,7 @@ function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps) header: ({ column }) => , cell: ({ row }) => { const n = row.original.prefixesTx - return n > 0 ? ( + return n !== null ? ( {fmtBgpNum(n)} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 4a1d73ce..0537c729 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.3, данные BGP; далее поля/контекст | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.4, поля BGP; далее URL/свежесть | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.4 — неподтверждённые поля и экспорт BGP (2026-09-30) + +- Новый каталог использует отдельный parser без defaults старого API: отсутствующие/некорректные таймеры, сообщения, prefix-count и AFI неизвестны. Наблюдаемый 0 сохранён, hold-time infinity отображается как 0. UPDATE-счётчики не объявляются числом префиксов; prefixesTx неизвестен, поскольку фактический каталог отправленных маршрутов не получен. Старый parser/API сохраняют прежнюю совместимость. +- API AFI отображается вместо придуманного IPv4, active prefixes не копируется из Rx. Неполная сумма неизвестна, сравнение пиров скрыто при пропуске Rx. Unknown не входит в число отказавших сессий. В карточках удалён hardcoded AS65001; AS берётся из сессий. +- Удалён RouterOS Export с выдуманными connection/filter/table параметрами; детали объясняют предел полученного API. Значения null и наблюдаемый ноль отображаются различно. +- Сверка: https://help.mikrotik.com/docs/spaces/ROS/pages/328220/BGP и /routing/bgp. 13 E2E, unit parser/Fastify, общий test:live-maps, TypeScript/lint/backend build прошли. Реальный RouterOS не проверен. Квота: 64% окна использовано на начале проверок. +- Далее — URL-контекст, свежесть каждого устройства, source/late-response и ПК/телефон. ASN/type fallback старого parser пока сохраняются и требуют отдельного уточнения. Локальный коммит без push. + ## Блок 10.3 — достоверность данных экрана BGP (2026-09-30) - Экран использует /bgp/catalog и валидирует каталог/inventory, уникальные ID, failures и принадлежность включённым устройствам. Частичный отказ сохраняет строки отказавшего устройства, успешный пустой опрос удаляет их; общий сбой сохраняет последний снимок. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index e6f28599..944d9deb 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -52,3 +52,7 @@ ## Блок 10.3 — данные BGP Экран мигрирован на каталог: валидация, кэш отказавшего устройства, неизвестные состояния, постоянный повтор и изоляция источника. 12 E2E/TypeScript/lint прошли. Далее — поля BGP, URL/свежесть и визуальная приёмка. + +## Блок 10.4 — поля BGP + +Новый parser сохраняет неизвестные таймеры/счётчики/AFI, не подменяет префиксы UPDATE-счётчиками; legacy API сохранён. Удалён придуманный экспорт. 13 E2E/unit/live-maps/TypeScript/lint/backend build прошли. Далее — URL/свежесть/источник/layout. diff --git a/lib/bgp/helpers.ts b/lib/bgp/helpers.ts index e1fc2f14..a917a4f6 100644 --- a/lib/bgp/helpers.ts +++ b/lib/bgp/helpers.ts @@ -1,11 +1,11 @@ -import type { BgpSessionRow } from "@/lib/bgp/types" -export function fmtBgpNum(n: number): string { +export function fmtBgpNum(n: number | null): string { + if (n === null) return "—" if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k` return String(n) } -export function rscBgpSnippet(s: BgpSessionRow): string { - return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main` +export function sumBgpCounters(values: (number | null)[]): number | null { + return values.some(n => n === null) ? null : values.reduce((sum, n) => sum + (n ?? 0), 0) } diff --git a/lib/bgp/response.ts b/lib/bgp/response.ts index 3d7c20f3..6057c4f9 100644 --- a/lib/bgp/response.ts +++ b/lib/bgp/response.ts @@ -7,8 +7,9 @@ export const bgpSessionSchema = z.object({ serverName: z.string(), serverSite: z.string(), serverCountry: z.string(), name: z.string(), peerIp: z.string(), remoteAs: counter, localAs: counter, localId: z.string(), remoteId: z.string(), state: z.string().trim().min(1), type: z.enum(["eBGP", "iBGP"]), - uptime: z.string().nullable(), holdTime: counter, keepalive: counter, - prefixesRx: counter, prefixesTx: counter, inputMessages: counter, outputMessages: counter, + uptime: z.string().nullable(), holdTime: counter.nullable(), keepalive: counter.nullable(), + prefixesRx: counter.nullable(), prefixesTx: counter.nullable(), inputMessages: counter.nullable(), outputMessages: counter.nullable(), + afi: z.string().nullable().optional(), capabilities: z.array(z.string()), lastError: z.string().nullable(), }) export const bgpCatalogSchema = z.object({ diff --git a/lib/bgp/types.ts b/lib/bgp/types.ts index f50dd06a..04741b10 100644 --- a/lib/bgp/types.ts +++ b/lib/bgp/types.ts @@ -14,15 +14,15 @@ export interface BgpSessionRow { description: string state: BgpState type: BgpType - afi: BgpAfi + afi: string uptime: string | null - holdTime: number - keepalive: number - prefixesRx: number - prefixesTx: number - prefixesActive: number - inputMessages: number - outputMessages: number + holdTime: number | null + keepalive: number | null + prefixesRx: number | null + prefixesTx: number | null + prefixesActive: number | null + inputMessages: number | null + outputMessages: number | null capabilities: string[] lastError: string | null } diff --git a/tests/e2e/bgp-data.spec.ts b/tests/e2e/bgp-data.spec.ts index 8a6a6906..0e8f82e5 100644 --- a/tests/e2e/bgp-data.spec.ts +++ b/tests/e2e/bgp-data.spec.ts @@ -70,3 +70,24 @@ test("BGP: частичный отказ сохраняет устройство await expect(rows).toHaveCount(0) await expect(page.getByText("Нет BGP-сессий", { exact: true })).toBeVisible() }) + +test("BGP: неизвестные поля и наблюдаемый ноль не подменяются", async ({ page }) => { + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + const unknown = { ...session(1), state: "Неизвестное состояние RouterOS", holdTime: null, keepalive: null, prefixesRx: null, prefixesTx: null, inputMessages: null, outputMessages: null, afi: "ip,ipv6" } + return r.fulfill({ json: path === "/api/servers" ? [server(1), server(2)] : path === "/api/bgp/catalog" ? { sessions: [unknown, { ...session(2), prefixesRx: 0, prefixesTx: 0 }], failures: [] } : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/bgp") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(2) + await expect(rows.first()).toContainText("Неизвестно") + await expect(rows.last().locator("td").nth(7)).toHaveText("0") + await rows.first().click() + await expect(page.getByText("ip,ipv6", { exact: true })).toBeVisible() + await expect(page.getByText("Нет данных API", { exact: true })).toBeVisible() + await expect(page.getByText("RouterOS Export", { exact: true })).toHaveCount(0) + await page.getByRole("button", { name: "Аналитика", exact: true }).click() + await expect(page.getByLabel("Сводка префиксов BGP")).toContainText("нет данных API") + await expect(page.getByText("Недостаточно данных о префиксах для сравнения", { exact: true })).toBeVisible() +})