fix(bgp): отображать только подтверждённые показатели сессий
This commit is contained in:
1 parent
7046808429
commit
8fdc4b917b
13 files changed
+144
-165
No files matched your search
+24
-26
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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">
|
||||
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}s / ${session.keepalive}s` },
|
||||
{ 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)}`,
|
||||
},
|
||||
].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>
|
||||
{ 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>
|
||||
|
||||
{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>
|
||||
</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>
|
||||
|
||||
@@ -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 и принадлежность включённым устройствам. Частичный отказ сохраняет строки отказавшего устройства, успешный пустой опрос удаляет их; общий сбой сохраняет последний снимок.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user