diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index 0b43a0ef..ffea5159 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -197,6 +197,7 @@ const STATE_STYLE: Record = { + 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 ( - {type} + {type === "Unknown" ? "Тип неизвестен" : type} ) } @@ -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[] }) {

{router.label}

-

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

+

{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}

{estCnt > 0 && ( @@ -389,8 +390,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
- AS{s.remoteAs} - {AS_NAMES[s.remoteAs] && ( + {s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs} + {s.remoteAs !== null && AS_NAMES[s.remoteAs] && ( · {AS_NAMES[s.remoteAs]} )}
@@ -474,14 +475,14 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { { id: "ebgp", label: "eBGP сессий", - value: ebgpSessions, + value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions, icon: , iconClassName: "text-muted-foreground", }, { id: "ibgp", label: "iBGP сессий", - value: ibgpSessions, + value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions, icon: , iconClassName: "text-muted-foreground", }, @@ -504,7 +505,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{s.peerIp} - AS{s.remoteAs} + {s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}
@@ -556,7 +557,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { {/* eBGP vs iBGP */} - {sessions.length === 0 ? ( + {sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (

Нет данных

) : ( <> @@ -583,7 +584,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{Object.entries( sessions.filter(s => s.type === "eBGP").reduce>((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]) => (
diff --git a/backend/src/services/bgp-catalog-parse.test.ts b/backend/src/services/bgp-catalog-parse.test.ts index 481909a2..754a56fa 100644 --- a/backend/src/services/bgp-catalog-parse.test.ts +++ b/backend/src/services/bgp-catalog-parse.test.ts @@ -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") diff --git a/backend/src/services/bgp-parse-sessions.ts b/backend/src/services/bgp-parse-sessions.ts index 8407aa24..03109c8f 100644 --- a/backend/src/services/bgp-parse-sessions.ts +++ b/backend/src/services/bgp-parse-sessions.ts @@ -137,9 +137,10 @@ export function parseBgpSessions(server: ServerRow, raw: RosBgpSession[]): BgpSe } /** Каталог не заменяет отсутствующие показатели значениями по умолчанию старого API. */ -export type BgpCatalogSessionRead = Omit & { +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; + 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"]), diff --git a/components/data-grids/bgp-sessions-data-grid.tsx b/components/data-grids/bgp-sessions-data-grid.tsx index 07c185ad..bebdcd1b 100644 --- a/components/data-grids/bgp-sessions-data-grid.tsx +++ b/components/data-grids/bgp-sessions-data-grid.tsx @@ -62,6 +62,7 @@ const STATE_STYLE: Record = { + 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} ) } @@ -154,8 +155,8 @@ function BgpSessionsDataGrid({ sessions, emptyTitle, selectedSessionId, onSelect const as = row.original.remoteAs return (
- AS{as} - {BGP_AS_NAMES[as] && ( + {as === null ? "AS неизвестна" : "AS" + as} + {as !== null && BGP_AS_NAMES[as] && ( {BGP_AS_NAMES[as]} )}
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f3b84d21..f8636f7f 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 81f9b730..3bfe2a57 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -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. diff --git a/lib/bgp/response.ts b/lib/bgp/response.ts index 6057c4f9..bdb527a9 100644 --- a/lib/bgp/response.ts +++ b/lib/bgp/response.ts @@ -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(), diff --git a/lib/bgp/types.ts b/lib/bgp/types.ts index 04741b10..db661682 100644 --- a/lib/bgp/types.ts +++ b/lib/bgp/types.ts @@ -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 diff --git a/tests/e2e/bgp-data.spec.ts b/tests/e2e/bgp-data.spec.ts index 0e8f82e5..3afffccd 100644 --- a/tests/e2e/bgp-data.spec.ts +++ b/tests/e2e/bgp-data.spec.ts @@ -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()