fix(bgp): отображать только подтверждённые показатели сессий

This commit is contained in:
Denozordec committed 2026-09-30 16:56:28 +07:00
1 parent 7046808429
commit 8fdc4b917b
13 files changed
+148 -169

No files matched your search

+24 -26
View File
@@ -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[] }) {
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{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 (
<Frame key={router.id} dense className="w-full overflow-hidden">
@@ -360,7 +357,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none">{router.label}</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · AS65001</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · {Array.from(new Set(router.sessions.map(s => "AS" + s.localAs))).join(", ")}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{estCnt > 0 && (
@@ -398,12 +395,12 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
</div>
</div>
<div className="shrink-0 text-right">
{s.prefixesRx > 0 && (
{s.prefixesRx !== null && s.prefixesRx > 0 && (
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
↓ {fmtNum(s.prefixesRx)}
</p>
)}
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p>
<p className={cn("text-[10px] font-semibold", ss.text)}>{ss.label}</p>
</div>
</div>
)
@@ -411,7 +408,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
</div>
{/* footer summary */}
{totalRx > 0 && (
{totalRx !== null && totalRx > 0 && (
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
<ArrowDownIcon className="size-3 text-emerald-500" />
<span className="text-[11px] font-mono text-muted-foreground">
@@ -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: <GitMergeIcon className="size-4" />,
iconClassName: "text-info",
@@ -494,7 +492,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
<div className="flex flex-col gap-3">
{topPeers.map((s, i) => {
const pct = (s.prefixesRx / maxRx) * 100
const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
const ss = STATE_STYLE[s.state]
return (
<div key={s.id} className="flex items-center gap-3">
@@ -524,7 +522,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
)
})}
{topPeers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
<p className="text-sm text-muted-foreground text-center py-4">Недостаточно данных о префиксах для сравнения</p>
)}
</div>
</OpsPanel>
@@ -543,7 +541,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
return (
<div key={state} className="flex items-center gap-2">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<span className="text-xs w-24">{state}</span>
<span className="text-xs w-24">{ss.label}</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
</div>
@@ -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: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-estab",
label: "Не установлено",
value: incomplete ? "—" : notEstab,
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default",
@@ -826,7 +824,7 @@ function BgpPageContent() {
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
</AlertTitle>
<AlertDescription>
Проверьте {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(", ")}
</AlertDescription>
</Alert>
)}
+1 -1
View File
@@ -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"
},
+4 -2
View File
@@ -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<ServerRow[]>
findServer: (id: number) => Promise<ServerRow | undefined>
readCatalogSessions?: (server: ServerRow) => Promise<BgpCatalogSessionRead[]>
readSessions: (server: ServerRow) => Promise<BgpSessionRead[]>
}
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<ServerRow, BgpCatalogSessionRead | BgpSessionRead>(devices, dependencies.readCatalogSessions ?? dependencies.readSessions))
})
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
@@ -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")
@@ -135,3 +135,33 @@ export function parseBgpSessions(server: ServerRow, raw: RosBgpSession[]): BgpSe
}
})
}
/** Каталог не заменяет отсутствующие показатели значениями по умолчанию старого API. */
export type BgpCatalogSessionRead = Omit<BgpSessionRead, "holdTime" | "keepalive" | "prefixesRx" | "prefixesTx" | "inputMessages" | "outputMessages"> & {
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,
}
})
}
+17 -123
View File
@@ -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 (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</span>
)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ 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) => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }}
/>
</div>
<span className="w-14 text-right tabular-nums">{fmtBgpNum(r.val)}</span>
</div>
))}
</div>
)
}
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 (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ 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 }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{session.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">
Префиксы
</p>
<PrefixBar
rx={session.prefixesRx}
tx={session.prefixesTx}
active={session.prefixesActive}
/>
</div>
)}
{session.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
Capabilities
</p>
<div className="flex flex-wrap gap-1.5">
{session.capabilities.map((c) => (
<CapChip key={c} cap={c} />
))}
</div>
</div>
)}
{session.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{session.lastError}</p>
</div>
)}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
RouterOS Export
</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscBgpSnippet(session)}
</pre>
<button
type="button"
onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}
>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
return <div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
{session.state === "Unknown" && <p role="status" className="mb-3 text-xs text-muted-foreground">Состояние сессии неизвестно. Значения относятся к последнему полученному снимку.</p>}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ 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 }) => <div key={label}><p className="text-[10px] text-muted-foreground mb-0.5">{label}</p><p className="text-xs font-mono font-medium break-words">{value}</p></div>)}
</div>
)
{session.capabilities.length > 0 && <div className="mb-4"><p className="text-[10px] text-muted-foreground mb-1.5">Capabilities</p><div className="flex flex-wrap gap-1.5">{session.capabilities.map((cap, i) => <span key={i} className="rounded px-1.5 py-0.5 text-[10px] border bg-muted/60 text-muted-foreground">{cap}</span>)}</div></div>}
{session.lastError && <p className="mb-4 rounded-md border border-destructive/20 px-3 py-2 text-xs font-mono text-destructive break-words">{session.lastError}</p>}
<p className="text-xs text-muted-foreground">API возвращает состояние сессии. Конфигурация connection, routing-table и фильтров для экспорта здесь не получена.</p>
</div>
}
export { BgpSessionDetail }
@@ -223,7 +223,7 @@ function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps)
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↓" />,
cell: ({ row }) => {
const n = row.original.prefixesRx
return n > 0 ? (
return n !== null ? (
<span className="font-mono tabular-nums text-emerald-600 dark:text-emerald-400 text-right block">
{fmtBgpNum(n)}
</span>
@@ -243,7 +243,7 @@ function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps)
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↑" />,
cell: ({ row }) => {
const n = row.original.prefixesTx
return n > 0 ? (
return n !== null ? (
<span className="font-mono tabular-nums text-[var(--chart-tx)] text-right block">
{fmtBgpNum(n)}
</span>
+9 -1
View File
@@ -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 и принадлежность включённым устройствам. Частичный отказ сохраняет строки отказавшего устройства, успешный пустой опрос удаляет их; общий сбой сохраняет последний снимок.
+4
View File
@@ -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.
+4 -4
View File
@@ -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<number>((sum, n) => sum + (n ?? 0), 0)
}
+3 -2
View File
@@ -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({
+8 -8
View File
@@ -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
}
+21
View File
@@ -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()
})