fix(api): показывать ошибки опроса в каталоге BGP

This commit is contained in:
Denozordec committed 2026-09-30 16:41:45 +07:00
1 parent f9cfb4e532
commit 85e701960e
6 files changed
+114 -28

No files matched your search

+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",
"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"
},
+52
View File
@@ -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")
+32 -26
View File
@@ -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<ServerRow[]>
findServer: (id: number) => Promise<ServerRow | undefined>
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],
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()
+17
View File
@@ -0,0 +1,17 @@
export async function pollBgpCatalog<S extends { id: number; name: string }, T extends { id: string; serverId: number }>(
devices: readonly S[], read: (device: S) => Promise<T[]>,
): 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) }
}
+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, карта 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; сетевые правила не меняем.
+4
View File
@@ -44,3 +44,7 @@
## Блок 10.1 — подготовка
Прочитаны реализации BGP, OSPF, карты, оптимизатора и точки интеграции рекурсивных маршрутов/терминала. Сохранены порядок, схема, референсы и решение по совместимости BGP. Далее — серверный каталог с failures и проверкой контекста.
## Блок 10.2 — BGP API
Каталог sessions/failures и проверка контекста добавлены; старый агрегат совместим. Выборный отказ возвращает 503. Unit/Fastify/TypeScript/lint/backend build прошли. Следующий блок — потребитель нового каталога на экране BGP.