- {[
- { 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) => (
-
- )
-}
+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 }) => (
-
- ))}
-
-
- {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 }) =>
)}
- )
+ {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()
+})