diff --git a/backend/package.json b/backend/package.json index d76a9ec8..43455c73 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 && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.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 && tsx src/routes/ospf-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/ospf-catalog.test.ts b/backend/src/routes/ospf-catalog.test.ts new file mode 100644 index 00000000..bf8cd018 --- /dev/null +++ b/backend/src/routes/ospf-catalog.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import { createOspfRoutes } from "./ospf.js" +import { emptyOspfCatalog, pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js" +import { servers } from "../db/schema.js" +const devices = [{ id: 1, name: "Первый" }, { id: 2, name: "Второй" }] +const row = { id: "*1", serverId: 1 } +const collections = { ...emptyOspfCatalog(), neighbors: [row] } as OspfCatalogCollections +const read = async (device: { id: number }) => { if (device.id === 2) throw new Error("Ошибка BFD тест"); return collections } +const mixed = await pollOspfCatalog(devices, read) +assert.deepEqual(mixed, { ...collections, failures: [{ serverId: 2, serverName: "Второй", error: "Ошибка BFD тест" }] }) +assert.deepEqual(await pollOspfCatalog(devices, async () => emptyOspfCatalog()), { ...emptyOspfCatalog(), failures: [] }) +assert.equal((await pollOspfCatalog(devices, async () => { throw new Error("Отказ") })).failures.length, 2) +for (const rows of [[{ ...row, serverId: 2 }], [row, row], [{ ...row, id: " " }]]) { + const result = await pollOspfCatalog([devices[0]], async () => ({ ...emptyOspfCatalog(), routes: rows } as OspfCatalogCollections)) + assert.equal(result.routes.length, 0) + assert.equal(result.failures.length, 1) +} +const client = { getOspfNeighbors: async () => [], getOspfAreas: async () => [], getOspfInterfaceTemplates: async () => [], getOspfInstances: async () => [], getBfdSessions: async () => [], getIpRoutes: async () => [] } +assert.equal((await readOspfCatalogRaw(client)).bfdSessions.length, 0) +for (const section of ["getBfdSessions", "getIpRoutes"] as const) await assert.rejects(() => readOspfCatalogRaw({ ...client, [section]: async () => { throw new Error("Отказ " + section) } }), /Отказ/) +const app = Fastify() +app.setValidatorCompiler(validatorCompiler) +app.setSerializerCompiler(serializerCompiler) +await app.register(createOspfRoutes({ listEnabledServers: async () => devices as typeof servers.$inferSelect[], readCatalog: read }), { prefix: "/api" }) +const response = await app.inject({ method: "GET", url: "/api/ospf/catalog" }) +assert.equal(response.statusCode, 200) +assert.deepEqual(response.json(), mixed) +await app.close() +console.log("OSPF catalog: partial/empty/failed/foreign/duplicate and strict BFD/routes, HTTP passed") diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts index dae0198c..0c99837e 100644 --- a/backend/src/routes/ospf.ts +++ b/backend/src/routes/ospf.ts @@ -11,6 +11,7 @@ import type { } from "../types/server.js" import { parseOspfGateway, parseOspfRouteType } from "../services/ospf-route-parse.js" import { z } from "zod" +import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js" type ServerRow = typeof servers.$inferSelect type TrafficSampleRow = typeof trafficSamples.$inferSelect @@ -544,7 +545,21 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): // ── route plugin ────────────────────────────────────────────────────────────── -const ospfRoutes: FastifyPluginAsyncZod = async (app) => { +interface OspfCatalogDependencies { + listEnabledServers: () => Promise + readCatalog: (server: ServerRow) => Promise +} +const catalogDefaults: OspfCatalogDependencies = { + listEnabledServers: () => db.select().from(servers).where(eq(servers.enabled, true)), + readCatalog: async server => { + const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await readOspfCatalogRaw(MikrotikClient.fromServer(server)) + const areaMap = buildAreaMap(areas) + return { neighbors: parseNeighbors(server, neighbors, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseInstances(server, instances), bfdSessions: parseBfdSessions(server, bfdSessions), routes: parseOspfRoutes(server, ipRoutes) } + }, +} +export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalogDefaults): FastifyPluginAsyncZod { + return async (app) => { + app.get("/ospf/catalog", async (_req, reply) => reply.send(await pollOspfCatalog(await dependencies.listEnabledServers(), dependencies.readCatalog))) // GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers app.get("/ospf/neighbors", async (_req, reply) => { @@ -748,4 +763,6 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => { ) } -export default ospfRoutes +} + +export default createOspfRoutes() diff --git a/backend/src/services/ospf-catalog-poll.ts b/backend/src/services/ospf-catalog-poll.ts new file mode 100644 index 00000000..05ed02f4 --- /dev/null +++ b/backend/src/services/ospf-catalog-poll.ts @@ -0,0 +1,29 @@ +import type { MikrotikClient } from "./mikrotik.js" +import type { OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead, OspfRouteRead } from "../types/server.js" +export interface OspfCatalogCollections { + neighbors: OspfNeighborRead[]; interfaces: OspfInterfaceRead[]; instances: OspfInstanceRead[]; + bfdSessions: BfdSessionRead[]; routes: OspfRouteRead[]; +} +export const emptyOspfCatalog = (): OspfCatalogCollections => ({ neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] }) +export async function readOspfCatalogRaw(client: Pick) { + const [neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes] = await Promise.all([ + client.getOspfNeighbors(), client.getOspfAreas(), client.getOspfInterfaceTemplates(), client.getOspfInstances(), client.getBfdSessions(), client.getIpRoutes(), + ]) + if ([neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes].some(rows => !Array.isArray(rows))) throw new Error("Некорректный ответ RouterOS OSPF") + return { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } +} +export async function pollOspfCatalog(devices: readonly S[], read: (device: S) => Promise) { + const results = await Promise.all(devices.map(async device => { + try { + const collections = await read(device) + for (const rows of Object.values(collections)) { + if (!Array.isArray(rows) || rows.some(row => row.serverId !== device.id || typeof row.id !== "string" || !row.id.trim())) throw new Error("Некорректный контекст OSPF-каталога") + if (new Set(rows.map(row => row.id)).size !== rows.length) throw new Error("Повторяющиеся ID OSPF-каталога") + } + return { ...collections, failures: [] } + } catch (error) { + return { ...emptyOspfCatalog(), failures: [{ serverId: device.id, serverName: device.name, error: error instanceof Error ? error.message : "Ошибка опроса OSPF" }] } + } + })) + return { neighbors: results.flatMap(r => r.neighbors), interfaces: results.flatMap(r => r.interfaces), instances: results.flatMap(r => r.instances), bfdSessions: results.flatMap(r => r.bfdSessions), routes: results.flatMap(r => r.routes), failures: results.flatMap(r => r.failures) } +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 19b6ee66..f1617697 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.8, локальная приёмка BGP; далее OSPF | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.9, строгий каталог OSPF; далее экран | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.9 — строгий каталог OSPF (2026-09-30) + +- Добавлен /ospf/catalog с пятью списками и failures по устройствам. Строгий reader не скрывает отказ BFD или /ip/route за []; успешный пустой ответ остаётся пустым. Чужой serverId, пустые/повторяющиеся ID в разделе отклоняются. +- Снимок устройства консервативно атомарен: отказ любой зависимости делает весь его набор неподтверждённым, исправные устройства возвращаются. Это не обещает независимую доступность BFD/core при частичном сбое одного роутера. Legacy /ospf/all и старый reader сохранены до миграции consumers; правила optimize/apply не менялись. +- Unit проверил partial/empty/all-failed/context/duplicates и реальные ветки строгого reader при отказе BFD/routes; Fastify inject с production Zod проверил HTTP каталог. Backend build прошёл. Lint без ошибок; два исходных warning в ospf.ts (and, measurePingByInterface) сохранены. +- Далее — потребитель /ospf/catalog: валидация/inventory, кэш, неизвестные статусы/сводка, readiness оптимизации и source. Квота: 80% окна использовано; продолжаем по прямому поручению пользователя. Локальный коммит без push. + ## Блок 10.8 — визуальная и профильная приёмка BGP (2026-09-30) - Два viewport 390×844/1440×1000, клавиатура и отсутствие общего переполнения проверены. Раскрытие сессии имеет явную кнопку с доступным именем/aria-expanded, Enter обновляет session URL. Выбранная сессия, скрытая фильтрами, имеет отдельное сообщение. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 197b16ff..3f4c877b 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -72,3 +72,7 @@ server/session/tab и возврат/reload сохраняют контекст; ## Блок 10.8 — BGP приёмка 27 E2E, unit/TypeScript/lint, frontend/backend build прошли. Шесть PNG просмотрены, мобильные подписи/детали исправлены. Далее — строгий каталог OSPF (BFD/routes сейчас скрывают свои ошибки). Внешние границы — журнал. + +## Блок 10.9 — OSPF каталог + +Строгий /ospf/catalog возвращает failures устройства; BFD/routes ошибки не скрываются. Снимок устройства атомарен, legacy /all и правила оптимизации сохранены. Unit/HTTP/backend build прошли; два исходных lint warning. Далее — экран OSPF.