fix(bgp): сохранять неизвестные AS и тип сессии

This commit is contained in:
Denozordec committed 2026-09-30 17:09:32 +07:00
1 parent 2e17cbe926
commit 5c307a5b6c
9 files changed
+47 -21

No files matched your search

+11 -10
View File
@@ -197,6 +197,7 @@ const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; lab
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
}
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
Unknown: { bg: "bg-muted", text: "text-muted-foreground" },
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
}
@@ -206,7 +207,7 @@ function TypeBadge({ type }: { type: BgpType }) {
return (
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
s.bg, s.text, "border-current/20")}>
{type}
{type === "Unknown" ? "Тип неизвестен" : type}
</span>
)
}
@@ -276,7 +277,7 @@ function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession
!s.description.toLowerCase().includes(q) &&
!s.serverLabel.includes(q) &&
!String(s.remoteAs).includes(q) &&
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
!(s.remoteAs === null ? "" : AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
) {
return false
}
@@ -358,7 +359,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} · {Array.from(new Set(router.sessions.map(s => "AS" + s.localAs))).join(", ")}</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{estCnt > 0 && (
@@ -389,8 +390,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
<TypeBadge type={s.type} />
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-[10px] text-muted-foreground">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
{s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
)}
</div>
@@ -474,14 +475,14 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{
id: "ebgp",
label: "eBGP сессий",
value: ebgpSessions,
value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ibgp",
label: "iBGP сессий",
value: ibgpSessions,
value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
@@ -504,7 +505,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<div className="flex items-center justify-between mb-1 gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono truncate">{s.peerIp}</span>
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span>
<span className="text-[10px] text-muted-foreground shrink-0">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
<TypeBadge type={s.type} />
</div>
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
@@ -556,7 +557,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{/* eBGP vs iBGP */}
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
{sessions.length === 0 ? (
{sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
<>
@@ -583,7 +584,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<div className="flex flex-col gap-1">
{Object.entries(
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
if (s.remoteAs !== null) acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
}, {})
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
<div key={as} className="flex items-center justify-between text-[11px]">
@@ -19,3 +19,10 @@ 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")
assert.equal(missing.remoteAs, null)
assert.equal(missing.localAs, null)
assert.equal(missing.type, "Unknown")
for (const value of ["0", "-1", "12bad", "4294967296"]) assert.equal(parseBgpCatalogSessions(server, [{ ".id": "*1", "remote.as": value }])[0].remoteAs, null)
assert.equal(parseBgpCatalogSessions(server, [{ ".id": "*1", "remote.as": "4294967295", "local.as": "4294967295" }])[0].type, "iBGP")
assert.equal(parseBgpCatalogSessions(server, [{ ".id": "*1", "remote.as": "64513", "local.as": "64512" }])[0].type, "eBGP")
+5 -1
View File
@@ -137,9 +137,10 @@ export function parseBgpSessions(server: ServerRow, raw: RosBgpSession[]): BgpSe
}
/** Каталог не заменяет отсутствующие показатели значениями по умолчанию старого API. */
export type BgpCatalogSessionRead = Omit<BgpSessionRead, "holdTime" | "keepalive" | "prefixesRx" | "prefixesTx" | "inputMessages" | "outputMessages"> & {
export type BgpCatalogSessionRead = Omit<BgpSessionRead, "holdTime" | "keepalive" | "prefixesRx" | "prefixesTx" | "inputMessages" | "outputMessages" | "remoteAs" | "localAs" | "type"> & {
holdTime: number | null; keepalive: number | null; prefixesRx: number | null; prefixesTx: number | null;
inputMessages: number | null; outputMessages: number | null; afi: string | null;
remoteAs: number | null; localAs: number | null; type: "eBGP" | "iBGP" | "Unknown";
}
function readCounter(value: string | undefined): number | null {
if (value === undefined || !/^\d+$/.test(value.trim())) return null
@@ -154,7 +155,10 @@ function readTimer(value: string | undefined): number | null {
export function parseBgpCatalogSessions(server: ServerRow, raw: RosBgpSession[]): BgpCatalogSessionRead[] {
return parseBgpSessions(server, raw).map((session, index) => {
const s = raw[index]
const asn = (value: string | undefined) => { const n = readCounter(value); return n !== null && n > 0 && n <= 4294967295 ? n : null }
const remoteAs = asn(s["remote.as"]), localAs = asn(s["local.as"])
return { ...session,
remoteAs, localAs, type: remoteAs === null || localAs === null ? "Unknown" : remoteAs === localAs ? "iBGP" : "eBGP",
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"]),
@@ -62,6 +62,7 @@ const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; lab
}
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
Unknown: { bg: "bg-muted", text: "text-muted-foreground" },
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
}
@@ -94,7 +95,7 @@ function TypeBadge({ type }: { type: BgpType }) {
"border-current/20",
)}
>
{type}
{type === "Unknown" ? "Тип неизвестен" : type}
</span>
)
}
@@ -154,8 +155,8 @@ function BgpSessionsDataGrid({ sessions, emptyTitle, selectedSessionId, onSelect
const as = row.original.remoteAs
return (
<div className="flex items-center gap-1.5 font-mono">
<span>AS{as}</span>
{BGP_AS_NAMES[as] && (
<span>{as === null ? "AS неизвестна" : "AS" + as}</span>
{as !== null && BGP_AS_NAMES[as] && (
<span className="text-muted-foreground text-[10px]">{BGP_AS_NAMES[as]}</span>
)}
</div>
+8 -1
View File
@@ -26,11 +26,18 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.5, BGP URL/свежесть; далее ASN/layout |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.6, BGP ASN/type; далее фильтры/layout |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.6 — неизвестные AS и тип BGP (2026-09-30)
- Новый catalog parser возвращает null для отсутствующей/некорректной AS, проверяет диапазон 1–4294967295; тип Unknown при недостаточном контексте. Legacy parser и его consumers сохранены.
- Экран/таблица показывают «AS неизвестна» и «Тип неизвестен» вместо AS0/eBGP. Неизвестный тип не добавляется к iBGP/eBGP; сводка типов и их сравнение не выдаёт неполный набор за полный.
- Unit проверил отсутствие/невалидные значения/верхнюю границу, равные и разные AS. 15 E2E BGP/OSPF прошли, frontend/backend TypeScript и lint чистые.
- Далее — URL фильтров/поиска, доступные состояния и desktop/mobile BGP, затем OSPF. AFI показывается фактической строкой API; варианты фильтра AFI требуют согласования с таким представлением. Реальный RouterOS не проверен; локальный коммит без push.
## Блок 10.5 — URL, свежесть и источник BGP (2026-09-30)
- URL хранит server/session/tab, сохраняет сторонние параметры; раскрытие строки связано с session. Возврат и reload восстанавливают контекст. Недоступное устройство/сессия из ссылки показывают сообщение и явный переход, не подменяются всеми устройствами. Включён preserveSelection установленного rail: тест выявил сброс при пустом начальном inventory.
+4
View File
@@ -60,3 +60,7 @@
## Блок 10.5 — BGP URL/свежесть
server/session/tab и возврат/reload сохраняют контекст; недоступный объект не подменяется. Время успешного опроса каждого устройства не обновляется при отказе. 19 различных E2E/TypeScript/lint прошли. Далее — ASN/type, URL фильтров и layout.
## Блок 10.6 — BGP ASN/type
Отсутствующие/невалидные ASN и тип неизвестны; legacy parser сохранён. Unit/15 E2E/TypeScript/lint прошли. Далее — URL фильтров и layout.
+2 -2
View File
@@ -5,8 +5,8 @@ const counter = z.number().int().nonnegative()
export const bgpSessionSchema = z.object({
id: z.string().trim().min(1), serverId: z.number().int().positive(),
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"]),
name: z.string(), peerIp: z.string(), remoteAs: counter.nullable(), localAs: counter.nullable(),
localId: z.string(), remoteId: z.string(), state: z.string().trim().min(1), type: z.enum(["eBGP", "iBGP", "Unknown"]),
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(),
+3 -3
View File
@@ -1,5 +1,5 @@
export type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm" | "Unknown"
export type BgpType = "eBGP" | "iBGP"
export type BgpType = "eBGP" | "iBGP" | "Unknown"
export type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
export interface BgpSessionRow {
@@ -8,8 +8,8 @@ export interface BgpSessionRow {
serverLabel: string
serverSite: string
peerIp: string
remoteAs: number
localAs: number
remoteAs: number | null
localAs: number | null
routerId: string
description: string
state: BgpState
+3 -1
View File
@@ -75,13 +75,15 @@ test("BGP: неизвестные поля и наблюдаемый ноль н
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" }
const unknown = { ...session(1), state: "Неизвестное состояние RouterOS", remoteAs: null, localAs: null, type: "Unknown", 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.first()).toContainText("AS неизвестна")
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()