From 85e701960e8c13fa752cae1e12d857f7989166a0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 16:41:45 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B5=20BGP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/routes/bgp-catalog.test.ts | 52 +++++++++++++++++++++ backend/src/routes/bgp.ts | 58 +++++++++++++----------- backend/src/services/bgp-catalog-poll.ts | 17 +++++++ docs/ux-ui-progress.md | 9 +++- docs/ux-ui-stage-10.md | 4 ++ 6 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 backend/src/routes/bgp-catalog.test.ts create mode 100644 backend/src/services/bgp-catalog-poll.ts diff --git a/backend/package.json b/backend/package.json index 70bcdaf8..2aef6b12 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", + "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": "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" }, diff --git a/backend/src/routes/bgp-catalog.test.ts b/backend/src/routes/bgp-catalog.test.ts new file mode 100644 index 00000000..7cdb0a36 --- /dev/null +++ b/backend/src/routes/bgp-catalog.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import { createBgpRoutes } from "./bgp.js" +import { pollBgpCatalog } from "../services/bgp-catalog-poll.js" +import { servers } from "../db/schema.js" +import type { BgpSessionRead } from "../types/server.js" + +const devices = [{ id: 1, name: "Первый" }, { id: 2, name: "Второй" }] +const row = { id: "*1", serverId: 1 } +const mixed = await pollBgpCatalog(devices, async device => { + if (device.id === 2) throw new Error("Таймаут BGP") + return [row] +}) +assert.deepEqual(mixed, { sessions: [row], failures: [{ serverId: 2, serverName: "Второй", error: "Таймаут BGP" }] }) +assert.deepEqual(await pollBgpCatalog(devices, async () => []), { sessions: [], failures: [] }) +assert.equal((await pollBgpCatalog(devices, async () => { throw new Error("Отказ") })).failures.length, 2) +for (const rows of [[{ ...row, serverId: 2 }], [row, row], [{ ...row, id: " " }]]) { + const result = await pollBgpCatalog([devices[0]], async () => rows) + assert.equal(result.sessions.length, 0) + assert.equal(result.failures.length, 1) +} +assert.deepEqual(await pollBgpCatalog([], async () => { throw new Error("Не вызывать") }), { sessions: [], failures: [] }) + +const app = Fastify() +app.setValidatorCompiler(validatorCompiler) +app.setSerializerCompiler(serializerCompiler) +let reads = 0 +await app.register(createBgpRoutes({ + listEnabledServers: async () => devices as typeof servers.$inferSelect[], + findServer: async id => devices.find(d => d.id === id) as typeof servers.$inferSelect | undefined, + readSessions: async device => { + reads++ + if (device.id === 2) throw new Error("Таймаут BGP") + return [row as BgpSessionRead] + }, +}), { prefix: "/api" }) +const catalog = await app.inject({ method: "GET", url: "/api/bgp/catalog" }) +assert.equal(catalog.statusCode, 200) +assert.deepEqual(catalog.json(), mixed) +const legacy = await app.inject({ method: "GET", url: "/api/bgp/sessions" }) +assert.deepEqual(legacy.json(), [row]) +const selected = await app.inject({ method: "GET", url: "/api/servers/2/bgp/sessions" }) +assert.equal(selected.statusCode, 503) +assert.equal(selected.json().error, "Таймаут BGP") +assert.deepEqual((await app.inject({ method: "GET", url: "/api/servers/1/bgp/sessions" })).json(), [row]) +assert.equal((await app.inject({ method: "GET", url: "/api/servers/99/bgp/sessions" })).statusCode, 404) +const before = reads +assert.equal((await app.inject({ method: "GET", url: "/api/servers/0/bgp/sessions" })).statusCode, 400) +assert.equal(reads, before) +await app.close() +console.log("BGP catalog: partial/empty/failed/context/duplicates and HTTP compatibility/503 passed") diff --git a/backend/src/routes/bgp.ts b/backend/src/routes/bgp.ts index 340b9f5a..07a7dd92 100644 --- a/backend/src/routes/bgp.ts +++ b/backend/src/routes/bgp.ts @@ -7,7 +7,26 @@ import { MikrotikClient } from "../services/mikrotik.js" import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" import type { BgpSessionRead } from "../types/server.js" -const bgpRoutes: FastifyPluginAsyncZod = async (app) => { +import { pollBgpCatalog } from "../services/bgp-catalog-poll.js" + +type ServerRow = typeof servers.$inferSelect +interface BgpDependencies { + listEnabledServers: () => Promise + findServer: (id: number) => Promise + readSessions: (server: ServerRow) => Promise +} +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], + readSessions: async server => parseBgpSessions(server, await MikrotikClient.fromServer(server).getBgpSessions()), +} +export function createBgpRoutes(dependencies: BgpDependencies = defaults): FastifyPluginAsyncZod { + return async (app) => { + // Отдельный каталог сохраняет совместимость старого массива для оповещений. + app.get("/bgp/catalog", async (_req, reply) => { + const devices = await dependencies.listEnabledServers() + return reply.send(await pollBgpCatalog(devices, dependencies.readSessions)) + }) // GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug) app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { @@ -26,41 +45,28 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => { // GET /api/bgp/sessions — aggregate from ALL enabled servers app.get("/bgp/sessions", async (_req, reply) => { - const allServers = await db.select().from(servers).where(eq(servers.enabled, true)) - - const results: BgpSessionRead[][] = await Promise.all( - allServers.map(async (server) => { - try { - const client = MikrotikClient.fromServer(server) - const raw = await client.getBgpSessions() - return parseBgpSessions(server, raw) - } catch { - return [] - } - }), - ) - - return reply.send(results.flat()) + const devices = await dependencies.listEnabledServers() + const catalog = await pollBgpCatalog(devices, dependencies.readSessions) + return reply.send(catalog.sessions) }) // GET /api/servers/:id/bgp/sessions — single server app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { const params = req.params as ServerIdParams - const server = (await db - .select().from(servers) - .where(eq(servers.id, params.id)) - .limit(1))[0] + const server = await dependencies.findServer(params.id) if (!server) return reply.status(404).send({ error: "Server not found" }) try { - const client = MikrotikClient.fromServer(server) - const raw = await client.getBgpSessions() - return reply.send(parseBgpSessions(server, raw)) - } catch { - return reply.send([]) + const catalog = await pollBgpCatalog([server], dependencies.readSessions) + if (catalog.failures.length) return reply.status(503).send({ error: catalog.failures[0].error }) + return reply.send(catalog.sessions) + } catch (error) { + return reply.status(503).send({ error: error instanceof Error ? error.message : "Ошибка опроса BGP" }) } }) } -export default bgpRoutes +} + +export default createBgpRoutes() diff --git a/backend/src/services/bgp-catalog-poll.ts b/backend/src/services/bgp-catalog-poll.ts new file mode 100644 index 00000000..d13cfc5a --- /dev/null +++ b/backend/src/services/bgp-catalog-poll.ts @@ -0,0 +1,17 @@ +export async function pollBgpCatalog( + devices: readonly S[], read: (device: S) => Promise, +): Promise<{ sessions: T[]; failures: Array<{ serverId: number; serverName: string; error: string }> }> { + const results = await Promise.all(devices.map(async device => { + try { + const sessions = await read(device) + if (!Array.isArray(sessions) || sessions.some(s => s.serverId !== device.id || typeof s.id !== 'string' || !s.id.trim())) { + throw new Error('Некорректный контекст BGP-каталога') + } + if (new Set(sessions.map(s => s.id)).size !== sessions.length) throw new Error('Повторяющиеся ID BGP-сессий') + return { sessions, failures: [] } + } catch (error) { + return { sessions: [], failures: [{ serverId: device.id, serverName: device.name, error: error instanceof Error ? error.message : 'Ошибка опроса BGP' }] } + } + })) + return { sessions: results.flatMap(r => r.sessions), failures: results.flatMap(r => r.failures) } +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 2886958a..f226ebec 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, карта API и совместимость BGP | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.2, каталог BGP; далее экран | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.2 — серверный каталог BGP (2026-09-30) + +- Добавлен /bgp/catalog: sessions и failures по устройствам. Poller различает успешный пустой ответ и отказ, отклоняет чужой serverId, пустые/повторяющиеся ID. Старый /bgp/sessions сохраняет массив для оповещений; его неоднозначность остаётся до 11Б. +- Выборный /servers/:id/bgp/sessions возвращает 503 при отказе вместо 200/[]; 404/валидация ID сохранены. Изолированные зависимости маршрута позволяют проверять HTTP без DB/RouterOS. +- Unit/Fastify проверили частичный/полный/пустой опрос, контекст/дубликаты, совместимый массив, 503/404/400 без лишнего опроса. TypeScript, lint и backend production build прошли; тест включён в test:live-maps. +- Далее — экран BGP: проверка нового каталога, кэш при частичных отказах, неизвестные статусы/сводка, источник и свежесть. Реальные устройства/JWT/БД не проверены. Локальный коммит без push. + ## Блок 10.1 — карта маршрутизации и диагностики (2026-09-30) - План сохранён в [ux-ui-stage-10.md](ux-ui-stage-10.md): API, проблемы достоверности, порядок, референсы и схема. Переиспользуем Frame/Data Grid/панели ReUI; сетевые правила не меняем. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 749b7b11..d572e9f0 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -44,3 +44,7 @@ ## Блок 10.1 — подготовка Прочитаны реализации BGP, OSPF, карты, оптимизатора и точки интеграции рекурсивных маршрутов/терминала. Сохранены порядок, схема, референсы и решение по совместимости BGP. Далее — серверный каталог с failures и проверкой контекста. + +## Блок 10.2 — BGP API + +Каталог sessions/failures и проверка контекста добавлены; старый агрегат совместим. Выборный отказ возвращает 503. Unit/Fastify/TypeScript/lint/backend build прошли. Следующий блок — потребитель нового каталога на экране BGP.