diff --git a/backend/package.json b/backend/package.json index cbed88f1..b20dfdd8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,8 +24,9 @@ "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 && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.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:recursive-routes && npm run test:config-sync && npm run test:ros-file", - "test:geoip": "tsx src/services/traffic-flow-geoip.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:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file", + "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts", + "test:ospf-optimizer": "tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/backend/src/routes/ospf-optimizer.test.ts b/backend/src/routes/ospf-optimizer.test.ts new file mode 100644 index 00000000..0a0a10fc --- /dev/null +++ b/backend/src/routes/ospf-optimizer.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod" +import {createOspfRoutes} from "./ospf.js" +import type {servers} from "../db/schema.js" +const row={id:"*1",interface:"ether1",currentCost:10,optimalCost:20,pingMs:0,dlMbps:0,ulMbps:0,score:0} +let plan:unknown=[row],writes=0,enabled=true,exists=true,failWrite=false +const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler) +await app.register(createOspfRoutes({ + listEnabledServers:async()=>[],readCatalog:async()=>{throw new Error("unused")}, + getOptimizationServer:async()=>exists?({id:1,name:"Роутер",host:"192.0.2.1",enabled} as typeof servers.$inferSelect):undefined, + buildOptimizationPlan:async()=>plan as typeof row[], + setTemplateCost:async()=>{writes++;if(failWrite)throw new Error("Частичный сетевой отказ")}, +}),{prefix:"/api"}) +const send=(preview=false,body:unknown={pingWeight:60})=>app.inject({method:"POST",url:"/api/servers/1/ospf/optimize"+(preview?"/preview":""),payload:body}) +const preview=await send(true);assert.equal(preview.statusCode,200);assert.equal(preview.json().changedCount,1);assert.equal(preview.json().pingWeight,60);assert.equal(writes,0) +const applied=await send();assert.equal(applied.statusCode,200);assert.equal(applied.json().optimizedCount,1);assert.equal(applied.json().applied.length,1);assert.equal(writes,1) +for(const invalid of [null,{},[row,row],[{...row,currentCost:0}],[{...row,optimalCost:65536}],[{...row,id:""}]]){ + plan=invalid;const before=writes;assert.equal((await send()).statusCode,500);assert.equal(writes,before) +} +plan=[row];enabled=false;assert.equal((await send()).statusCode,409);assert.equal((await send(true)).statusCode,409) +enabled=true;exists=false;assert.equal((await send()).statusCode,404);exists=true +const before=writes;assert.equal((await send(false,{pingWeight:101})).statusCode,400);assert.equal(writes,before) +failWrite=true;const failed=await send();assert.equal(failed.statusCode,500);assert.ok(failed.json().error.includes("Частичный сетевой отказ")) +failWrite=false;plan=[];assert.equal((await send()).json().optimizedCount,0) +await app.close();console.log("OSPF optimizer HTTP: invalid before write, readonly preview, disabled/missing/bad weight, network error and known empty; no DB/RouterOS") diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts index 8013ad13..d41e1e26 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, parseOspfCatalogRouteType } from "../services/ospf-route-parse.js" import { z } from "zod" +import {readEditableOspfTemplates,validateOspfOptimizationPlan} from "../services/ospf-optimizer-plan.js" import { observedInteger, observedDurationMs, observedBoolean } from "../services/ospf-observed-values.js" import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js" @@ -291,9 +292,7 @@ function normalizeSpeedMbps(row: TrafficSampleRow | undefined): { dlMbps: number return { dlMbps: dl, ulMbps: ul } } -function isRefInterfaceName(name: string): boolean { - return /^\(ref\s+\*.+\)$/.test(name.trim()) -} + function parsePingTimeMs(raw: string | undefined): number | null { if (!raw) return null @@ -350,10 +349,8 @@ type OspfRankedInterface = { } async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): Promise { - const { ifaceTemplates, areas, instances, neighbors } = await fetchServerOspf(server) - const areaMap = buildAreaMap(areas) - const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap) - const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface)) + const { ifaceTemplates, neighbors } = await fetchServerOspf(server) + const editable = readEditableOspfTemplates(ifaceTemplates) const localLatencyMs = await getLatestSnapshotLatencyMs(server.id) const latestTraffic = await latestTrafficByInterface(server.id) @@ -576,6 +573,9 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): interface OspfCatalogDependencies { listEnabledServers: () => Promise readCatalog: (server: ServerRow) => Promise + getOptimizationServer?: (id:number)=>Promise + buildOptimizationPlan?: (server:ServerRow,weight:number)=>Promise + setTemplateCost?: (server:ServerRow,id:string,cost:number)=>Promise } const catalogDefaults: OspfCatalogDependencies = { listEnabledServers: () => db.select().from(servers).where(eq(servers.enabled, true)), @@ -725,16 +725,14 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, 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 = dependencies.getOptimizationServer ? await dependencies.getOptimizationServer(params.id) : (await db.select().from(servers).where(eq(servers.id,params.id)).limit(1))[0] if (!server) return reply.status(404).send({ error: "Server not found" }) + if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"}) try { const pingWeight = req.body.pingWeight - const ranked = await buildOspfOptimizationPlan(server, pingWeight) + const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight)) const changed = ranked.filter((r) => r.currentCost !== r.optimalCost) return reply.send({ serverId: server.id, @@ -757,22 +755,21 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, 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 = dependencies.getOptimizationServer ? await dependencies.getOptimizationServer(params.id) : (await db.select().from(servers).where(eq(servers.id,params.id)).limit(1))[0] if (!server) return reply.status(404).send({ error: "Server not found" }) + if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"}) try { const pingWeight = req.body.pingWeight - const ranked = await buildOspfOptimizationPlan(server, pingWeight) + const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight)) - const client = MikrotikClient.fromServer(server) + const client=dependencies.setTemplateCost?null:MikrotikClient.fromServer(server) const changed = ranked.filter((r) => r.currentCost !== r.optimalCost) const applied: Array<{ interface: string; from: number; to: number }> = [] for (const item of changed) { - await client.setOspfInterfaceTemplateCost(item.id, item.optimalCost) + if(dependencies.setTemplateCost) await dependencies.setTemplateCost(server,item.id,item.optimalCost) + else await client!.setOspfInterfaceTemplateCost(item.id,item.optimalCost) applied.push({ interface: item.interface, from: item.currentCost, to: item.optimalCost }) } diff --git a/backend/src/services/ospf-optimizer-plan.test.ts b/backend/src/services/ospf-optimizer-plan.test.ts new file mode 100644 index 00000000..f17b5cee --- /dev/null +++ b/backend/src/services/ospf-optimizer-plan.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import {readEditableOspfTemplates,validateOspfOptimizationPlan} from "./ospf-optimizer-plan.js" +const raw={".id":"*1",interfaces:"ether1",disabled:"false",cost:"10"} +assert.deepEqual(readEditableOspfTemplates([raw]),[{id:"*1",interface:"ether1",cost:10}]) +assert.deepEqual(readEditableOspfTemplates([{...raw,disabled:"true",cost:undefined},{...raw,".id":"*2",interfaces:"*ref",cost:undefined}]),[]) +assert.deepEqual(readEditableOspfTemplates([]),[]) +for(const payload of [null,{},[null],[raw,raw],[{...raw,cost:undefined}],[{...raw,cost:"10junk"}],[{...raw,cost:"0"}],[{...raw,disabled:undefined}],[{...raw,interfaces:""}],[raw,{...raw,".id":"*2"}]])assert.throws(()=>readEditableOspfTemplates(payload)) +const row={id:"*1",interface:"ether1",currentCost:10,optimalCost:20,pingMs:0,dlMbps:0,ulMbps:0,score:0} +assert.equal(validateOspfOptimizationPlan([row])[0].pingMs,0) +assert.deepEqual(validateOspfOptimizationPlan([]),[]) +for(const payload of [null,{},[row,row],[{...row,currentCost:0}],[{...row,optimalCost:65536}],[{...row,pingMs:-1}],[{...row,score:NaN}],[row,{...row,id:"*2"}]])assert.throws(()=>validateOspfOptimizationPlan(payload)) +console.log("OSPF optimizer plan: observed cost/disabled/id, ref/disabled skip, duplicate/invalid refusal, zero metrics") diff --git a/backend/src/services/ospf-optimizer-plan.ts b/backend/src/services/ospf-optimizer-plan.ts new file mode 100644 index 00000000..8a9a4b7b --- /dev/null +++ b/backend/src/services/ospf-optimizer-plan.ts @@ -0,0 +1,28 @@ +import {z} from "zod" +import {observedBoolean,observedInteger} from "./ospf-observed-values.js" +const templateSchema=z.array(z.object({".id":z.string().trim().min(1),interfaces:z.string().optional(),cost:z.string().optional(),disabled:z.string().optional()})) +export function readEditableOspfTemplates(payload:unknown){ + const parsed=templateSchema.safeParse(payload) + if(!parsed.success || new Set(parsed.data.map(row=>row[".id"])).size!==parsed.data.length)throw new Error("Недостоверный каталог шаблонов OSPF") + const names=new Set() + return parsed.data.flatMap(row=>{ + const disabled=observedBoolean(row.disabled) + if(disabled===null)throw new Error("Неизвестен disabled шаблона OSPF "+row[".id"]) + if(disabled)return [] + const name=row.interfaces?.trim() + if(!name)throw new Error("Неизвестен интерфейс шаблона OSPF "+row[".id"]) + if(name.startsWith("*"))return [] + const cost=observedInteger(row.cost,1,65535) + if(cost===null)throw new Error("Неизвестен или неподдерживаем cost шаблона OSPF "+row[".id"]) + if(names.has(name))throw new Error("Несколько активных шаблонов OSPF для интерфейса "+name) + names.add(name) + return [{id:row[".id"],interface:name,cost}] + }) +} +const measured=z.number().finite().nonnegative() +const rowSchema=z.object({id:z.string().trim().min(1),interface:z.string().trim().min(1),currentCost:z.number().int().min(1).max(65535),optimalCost:z.number().int().min(1).max(65535),score:z.number().finite(),pingMs:measured,dlMbps:measured,ulMbps:measured}) +export function validateOspfOptimizationPlan(payload:unknown){ + const parsed=z.array(rowSchema).safeParse(payload) + if(!parsed.success || new Set(parsed.data.map(row=>row.id)).size!==parsed.data.length || new Set(parsed.data.map(row=>row.interface)).size!==parsed.data.length)throw new Error("Недостоверный план оптимизации OSPF") + return parsed.data +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 8e4c3639..d4633ba0 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,19 @@ | 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.32; подтверждённое OSPF apply и RBAC, далее backend provenance | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.33; backend проверяет cost/план OSPF, далее layout/контекст | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.33 — достоверность cost и плана OSPF (2026-09-30) + +- Оптимизатор использует отдельный observed selector шаблонов: disabled/id/interface/currentCost должны быть достоверны. Cost не подставляется как 10, disabled не превращается в false. Missing/unsupported cost (включая 0 вне текущей write-policy 1–65535), дубликаты id и несколько активных шаблонов одного интерфейса отклоняются. Известные disabled и reference-интерфейсы сохраняют прежнее исключение из оптимизации. +- Весь ranked plan валидируется до первой записи; переполнение optimalCost, плохие метрики/идентификаторы не приводят к частичному apply. Отключённое устройство возвращает 409, неизвестное 404. Формула score, выбор проб и порядок сетевых изменений прежние. +- DI оптимизатора позволяет HTTP injection без БД/RouterOS. Проверены malformed plans до write, preview без write, success/empty, disabled/missing/bad weight и сетевой отказ. Unit selector проверяет unknown/default/duplicates и observed zero метрик. +- Проверки: test:ospf-optimizer, общий test:live-maps (включая OSPF/BGP/GRE/VXLAN compatibility), backend build прошли. Lint без ошибок; два прежних предупреждения unused and/measurePingByInterface остаются. +- Далее 10.34: адаптивность и keyboard/screen оптимизатора; затем URL/source context и различение observed/model метрик. Snapshot/traffic fallback и clamp значений в модели ещё сохранены и требуют отдельного отображения; весь раздел ещё не принят. Реальная RouterOS версия/DB не проверена. Последняя квота: 82% окна, 44% недели использовано. Локальный коммит без push. + ## Блок 10.32 — подтверждение применения OSPF и права (2026-09-30) - Apply валидирует существующий ospfOptimizeSchema, serverId и вес запроса. Успех по count/name без списка applied больше не принимается. Последний receipt привязан к контексту и времени получения браузером; отсутствие применения отображается «—». diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 949b4e89..14707d66 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -180,3 +180,7 @@ Live таймерный успех убран; demo симуляция явно ## 10.32. Подтверждённое OSPF применение и RBAC Strict receipt/server/weight, ref lock, readonly/disabled контекст и reread любого результата. 16 E2E, readonly повтор, TS/lint, обе production сборки и auth tests прошли. Specific OSPF rights согласованы: preview read, apply write; signed JWT со stub handlers без RouterOS/DB. Далее backend provenance: legacy cost/metric defaults ещё не приняты как observed данные. + +## 10.33. Backend cost и валидация плана + +Observed selector вместо default cost/disabled; неоднозначные шаблоны отклоняются до write. Полный план валидируется, disabled сервер 409. DI HTTP/unit без RouterOS/DB, live-maps regression/backend build прошли; lint два прежних unused warning. Ранжирование прежнее, model fallback/clamp ещё требуют отдельного отображения. Далее layout/контекст.