From 707784465541772d9936afb811bc517017c4164e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 00:50:17 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D1=85=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BA=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20OSPF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/routes/ospf-optimizer.test.ts | 5 +++++ backend/src/routes/ospf.ts | 4 ++++ .../services/ospf-optimizer-metrics.test.ts | 14 ++++++++++++++ .../src/services/ospf-optimizer-metrics.ts | 16 ++++++++++++++++ backend/src/services/ospf-optimizer-plan.ts | 3 ++- ...route-optimizer-ospf-preview-data-grid.tsx | 19 ++++++++++++++----- docs/ux-ui-progress.md | 10 +++++++++- docs/ux-ui-stage-10.md | 4 ++++ lib/ospf-response.ts | 3 ++- packages/contracts/src/index.ts | 2 ++ packages/contracts/src/ospf-optimizer.ts | 9 +++++++++ tests/e2e/route-optimizer-observed.spec.ts | 14 ++++++++++++++ 13 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 backend/src/services/ospf-optimizer-metrics.test.ts create mode 100644 backend/src/services/ospf-optimizer-metrics.ts create mode 100644 packages/contracts/src/ospf-optimizer.ts diff --git a/backend/package.json b/backend/package.json index b20dfdd8..b65ccfc9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,7 +26,7 @@ "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: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" + "test:ospf-optimizer": "tsx src/services/ospf-optimizer-metrics.test.ts && 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 index 0a0a10fc..4873e4f0 100644 --- a/backend/src/routes/ospf-optimizer.test.ts +++ b/backend/src/routes/ospf-optimizer.test.ts @@ -14,6 +14,11 @@ await app.register(createOspfRoutes({ }),{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 unknown={value:null,source:"unknown",sourceId:null,measuredAt:null}; +plan=[{...row,observed:{ping:{value:0,source:"speed-probe",sourceId:"p",measuredAt:"2020-01-01T00:00:00Z"},dl:unknown,ul:unknown}}]; +const withObserved=await send(true);assert.equal(withObserved.json().interfaces[0].observed.ping.value,0);assert.equal(withObserved.json().changes[0].observed.ping.source,"speed-probe"); +const beforeBad=writes;plan=[{...row,observed:{ping:{value:-1,source:"speed-probe",sourceId:"p",measuredAt:null},dl:unknown,ul:unknown}}];assert.equal((await send()).statusCode,500);assert.equal(writes,beforeBad); +plan=[row]; 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) diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts index d41e1e26..8405e523 100644 --- a/backend/src/routes/ospf.ts +++ b/backend/src/routes/ospf.ts @@ -11,6 +11,8 @@ import type { } from "../types/server.js" import { parseOspfGateway, parseOspfRouteType, parseOspfCatalogRouteType } from "../services/ospf-route-parse.js" import { z } from "zod" +import {readOspfOptimizerObserved} from "../services/ospf-optimizer-metrics.js" +import type {OspfOptimizerObserved} from "@mmapp/contracts" 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" @@ -338,6 +340,7 @@ async function measurePingByInterface( } type OspfRankedInterface = { + observed?: OspfOptimizerObserved id: string interface: string currentCost: number @@ -556,6 +559,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): return { id: iface.id, + observed: readOspfOptimizerObserved(bestProbe,t), interface: iface.interface, currentCost: iface.cost, pingMs, diff --git a/backend/src/services/ospf-optimizer-metrics.test.ts b/backend/src/services/ospf-optimizer-metrics.test.ts new file mode 100644 index 00000000..3a01880a --- /dev/null +++ b/backend/src/services/ospf-optimizer-metrics.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict" +import {readOspfOptimizerObserved} from "./ospf-optimizer-metrics.js" +const probe={id:"p",lastPingRttMs:0,lastPingAt:"2020-01-01 00:00:00+00",lastTxAvgMbps:0,lastRxAvgMbps:null} +const traffic={interfaceName:"ether1",rxBps:1234567,txBps:0,sampledAt:"2020-01-02 00:00:00+00"} +const observed=readOspfOptimizerObserved(probe,traffic) +assert.deepEqual(observed.ping,{value:0,source:"speed-probe",sourceId:"p",measuredAt:"2020-01-01T00:00:00.000Z"}) +assert.equal(observed.dl.value,0);assert.equal(observed.dl.source,"speed-probe");assert.equal(observed.dl.measuredAt,null) +assert.equal(observed.ul.value,0);assert.equal(observed.ul.source,"interface-traffic");assert.equal(observed.ul.measuredAt,"2020-01-02T00:00:00.000Z") +assert.equal(readOspfOptimizerObserved(undefined,traffic).dl.value,1.234567) +assert.deepEqual(readOspfOptimizerObserved(undefined,undefined),{ping:{value:null,source:"unknown",sourceId:null,measuredAt:null},dl:{value:null,source:"unknown",sourceId:null,measuredAt:null},ul:{value:null,source:"unknown",sourceId:null,measuredAt:null}}) +assert.throws(()=>readOspfOptimizerObserved({...probe,lastPingRttMs:-1},traffic)) +assert.throws(()=>readOspfOptimizerObserved({...probe,lastPingAt:"invalid"},traffic)) +assert.throws(()=>readOspfOptimizerObserved(undefined,{...traffic,txBps:-1})) +console.log("OSPF observed metrics: raw zero/null/fractions, provenance/old timestamps, no speed date inference; no DB/RouterOS") diff --git a/backend/src/services/ospf-optimizer-metrics.ts b/backend/src/services/ospf-optimizer-metrics.ts new file mode 100644 index 00000000..7f87006e --- /dev/null +++ b/backend/src/services/ospf-optimizer-metrics.ts @@ -0,0 +1,16 @@ +import {ospfOptimizerObservedSchema,type OspfOptimizerObserved} from "@mmapp/contracts" +interface Probe {id:string;lastPingRttMs:number|null;lastPingAt:string|null;lastTxAvgMbps:number|null;lastRxAvgMbps:number|null} +interface Traffic {interfaceName:string;rxBps:number;txBps:number;sampledAt:string} +const unknown=()=>({value:null,source:"unknown" as const,sourceId:null,measuredAt:null}) +function timestamp(value:string|null){return value===null?null:new Date(value).toISOString()} +export function readOspfOptimizerObserved(probe:Probe|undefined,traffic:Traffic|undefined):OspfOptimizerObserved{ + function speed(value:number|null|undefined,field:"rxBps"|"txBps"){ + if(value!=null&&probe)return {value,source:"speed-probe" as const,sourceId:probe.id,measuredAt:null} + if(traffic)return {value:traffic[field]/1_000_000,source:"interface-traffic" as const,sourceId:traffic.interfaceName,measuredAt:timestamp(traffic.sampledAt)} + return unknown() + } + return ospfOptimizerObservedSchema.parse({ + ping:probe?.lastPingRttMs!=null?{value:probe.lastPingRttMs,source:"speed-probe",sourceId:probe.id,measuredAt:timestamp(probe.lastPingAt)}:unknown(), + dl:speed(probe?.lastTxAvgMbps,"rxBps"),ul:speed(probe?.lastRxAvgMbps,"txBps"), + }) +} diff --git a/backend/src/services/ospf-optimizer-plan.ts b/backend/src/services/ospf-optimizer-plan.ts index 8a9a4b7b..308daca0 100644 --- a/backend/src/services/ospf-optimizer-plan.ts +++ b/backend/src/services/ospf-optimizer-plan.ts @@ -1,4 +1,5 @@ import {z} from "zod" +import {ospfOptimizerObservedSchema} from "@mmapp/contracts" 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){ @@ -20,7 +21,7 @@ export function readEditableOspfTemplates(payload:unknown){ }) } 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}) +const rowSchema=z.object({observed:ospfOptimizerObservedSchema.optional(),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") diff --git a/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx b/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx index 82d38d0f..fc8bf32e 100644 --- a/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx +++ b/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx @@ -3,6 +3,7 @@ import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" import { useMemo } from "react" +import type {OspfOptimizerObserved} from "@mmapp/contracts" import { type ColumnDef, useTable, @@ -17,7 +18,15 @@ import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sor import { EmptyState } from "@/components/empty-state" import { NetworkIcon } from "lucide-react" +function describeObserved(metric:OspfOptimizerObserved["ping"]|undefined,unit:string){ + if(!metric || metric.value===null)return "Измерение: —" + const source=metric.source==="speed-probe"?"Проба":"Трафик интерфейса" + const date=metric.measuredAt?new Date(metric.measuredAt).toLocaleString("ru-RU"):"дата неизвестна" + return source+": "+metric.value+" "+unit+" · "+date +} + export interface OspfPreviewInterfaceRow { + observed?: OspfOptimizerObserved id: string interface: string currentCost: number @@ -88,19 +97,19 @@ function RouteOptimizerOspfPreviewDataGrid({ { id: "pingMs", accessorKey: "pingMs", - header: ({ column }) => , - cell: ({ row }) => {row.original.pingMs}ms, - meta: { headerTitle: "Ping", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, + header: ({ column }) => , + cell: ({ row }) =>
{row.original.pingMs}ms

{describeObserved(row.original.observed?.ping,"мс")}

, + meta: { headerTitle: "Ping модели", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "speed", - header: () => Speed (dl/ul), + header: () => Speed (dl/ul) · модель, enableSorting: false, cell: ({ row }) => { const r = row.original return ( - ↓{r.dlMbps} / ↑{r.ulMbps} + ↓{r.dlMbps} / ↑{r.ulMbps}↓ {describeObserved(r.observed?.dl,"Мбит/с")}
↑ {describeObserved(r.observed?.ul,"Мбит/с")}
) }, diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 2e235211..fa4f6b47 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.39; исходные WAN/JH метрики отделены от модели, далее backend OSPF provenance | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.40; OSPF provenance добавлен, далее идентичность Router ID и визуальная приёмка | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.40 — происхождение метрик OSPF API (2026-10-01) + +- Общий контракт @mmapp/contracts: value/source/sourceId/measuredAt для Ping/DL/UL. Источники speed-probe, interface-traffic, unknown; неизвестность не становится нулём. Задержка трафиком не измеряется. Backend добавляет observed данные до валидации ranked plan; некорректные данные отклоняются до write. Ranking/model defaults прежние. +- Исходные нули, дроби и давние даты сохраняются. Timestamp Ping пробы не используется как время speed; traffic snapshot даёт собственную дату и bps/1e6. Задержка серверных snapshot остаётся только модельным fallback, не измерением интерфейса. +- Preview показывает модель и исходный снимок раздельно. Старые ответы без optional observed совместимы, но их измерения отображаются неизвестными. UI не выдаёт model-only API поля за observed. +- Проверки: backend metric/parser/HTTP optimizer tests, test:live-maps, 30 E2E observed/preview/apply/tables, TS/lint, production frontend/backend прошли. ReUI consumer-композиция сохранена. +- Далее: убрать неоднозначность Router ID в backend optimizer (сейчас first-match при опросе), затем обновить screenshots и локальную приёмку оптимизатора. Настоящие RouterOS/БД запросы не выполнялись. + ## Блок 10.39 — исходные метрики цепочек оптимизатора (2026-10-01) - Live WAN–JH/JH–Exit сохраняют observed record отдельно от model полей. Исходные 0, дроби, null и дата пробы не округляются/не ограничиваются WAN cap. Расчётные поля и ranking остаются прежними. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 1a68cabe..e3ee152f 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -208,3 +208,7 @@ server сохраняет выбор/reload/history без потери друг ## 10.39. Observed метрики WAN–JH/JH–Exit Исходные значения/дата пробы отдельно от неизменной модели. Матрица не подменяет unknown моделью, полные цепочки явно расчётные. 2 unit, 9 E2E, TS/lint и ReUI audit прошли. Далее backend OSPF provenance; screenshots ещё требуют обновления. + +## 10.40. OSPF provenance API + +Shared контракт и backend observed исходные значения/даты, UI модель отдельно от снимка. Legacy без observed даёт неизвестность; rank/write прежние. Backend optimizer/live-maps, 30 E2E, TS/lint и обе production сборки прошли. Далее Router ID ambiguity, затем visual acceptance. diff --git a/lib/ospf-response.ts b/lib/ospf-response.ts index 8789db0a..de7c7e92 100644 --- a/lib/ospf-response.ts +++ b/lib/ospf-response.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import {ospfOptimizerObservedSchema} from "@mmapp/contracts" import { vxlanServersSchema } from "@/lib/vxlan-response" const counter = z.number().int().nonnegative() const base = z.object({ id: z.string().trim().min(1), serverId: z.number().int().positive(), serverName: z.string(), serverSite: z.string(), serverCountry: z.string().default("UN") }) @@ -20,5 +21,5 @@ export const ospfOptimizeSchema = z.object({ serverId: z.number().int().positive(), serverName: z.string(), pingWeight: z.number().int().min(0).max(100), optimizedCount: z.number().int().nonnegative(), applied: z.array(z.object({ interface: z.string().min(1), from: z.number().int().min(1).max(65535), to: z.number().int().min(1).max(65535) })), - interfaces: z.array(z.object({ id: z.string().trim().min(1), interface: z.string(), currentCost: z.number().int().min(1).max(65535), optimalCost: z.number().int().min(1).max(65535), score: z.number().finite(), pingMs: z.number().finite().nonnegative(), dlMbps: z.number().finite().nonnegative(), ulMbps: z.number().finite().nonnegative() })), + interfaces: z.array(z.object({ observed: ospfOptimizerObservedSchema.optional(), id: z.string().trim().min(1), interface: z.string(), currentCost: z.number().int().min(1).max(65535), optimalCost: z.number().int().min(1).max(65535), score: z.number().finite(), pingMs: z.number().finite().nonnegative(), dlMbps: z.number().finite().nonnegative(), ulMbps: z.number().finite().nonnegative() })), }).refine(data => data.optimizedCount === data.applied.length && new Set(data.interfaces.map(row => row.id)).size === data.interfaces.length && new Set(data.applied.map(row => row.interface)).size === data.applied.length && data.applied.every(row => data.interfaces.some(iface => iface.interface === row.interface && iface.currentCost === row.from && iface.optimalCost === row.to))) diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0ad33764..728e0f72 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,3 +9,5 @@ export * from "./users.js" export * from "./traffic-flow.js" export * from "./geoip.js" export * from "./statistics.js" + +export * from "./ospf-optimizer.js" diff --git a/packages/contracts/src/ospf-optimizer.ts b/packages/contracts/src/ospf-optimizer.ts new file mode 100644 index 00000000..5af8e4be --- /dev/null +++ b/packages/contracts/src/ospf-optimizer.ts @@ -0,0 +1,9 @@ +import {z} from "zod" +export const ospfOptimizerMetricSchema=z.object({ + value:z.number().finite().nonnegative().nullable(), + source:z.enum(["speed-probe","interface-traffic","unknown"]), + sourceId:z.string().min(1).nullable(), + measuredAt:z.string().datetime({offset:true}).nullable(), +}).refine(metric=>metric.value===null ? metric.source==="unknown"&&metric.sourceId===null&&metric.measuredAt===null : metric.source!=="unknown"&&metric.sourceId!==null) +export const ospfOptimizerObservedSchema=z.object({ping:ospfOptimizerMetricSchema,dl:ospfOptimizerMetricSchema,ul:ospfOptimizerMetricSchema}).refine(observed=>observed.ping.source!=="interface-traffic") +export type OspfOptimizerObserved=z.infer diff --git a/tests/e2e/route-optimizer-observed.spec.ts b/tests/e2e/route-optimizer-observed.spec.ts index 089f5b07..12662ef2 100644 --- a/tests/e2e/route-optimizer-observed.spec.ts +++ b/tests/e2e/route-optimizer-observed.spec.ts @@ -13,3 +13,17 @@ for(const hasProbe of [true,false])test("Оптимизатор: исходны await page.getByRole("button",{name:"Маршруты (1)",exact:true}).click() await expect(page.getByRole("table").filter({hasText:"# Маршрут"})).toContainText("Ping модели") }) + +test("Оптимизатор: OSPF исходный ноль и происхождение метрик",async({page})=>{ + const unknown={value:null,source:"unknown",sourceId:null,measuredAt:null} + const item={id:"i",interface:"ether1",currentCost:10,optimalCost:20,score:50,pingMs:1,dlMbps:10,ulMbps:10,observed:{ping:{value:0,source:"speed-probe",sourceId:"p",measuredAt:"2020-01-01T00:00:00Z"},dl:unknown,ul:{value:0,source:"interface-traffic",sourceId:"ether1",measuredAt:"2020-01-02T00:00:00Z"}}} + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[{id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null}]:p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:p.endsWith("/preview")?{serverId:1,serverName:"Router",pingWeight:60,interfacesTotal:1,changedCount:1,unchangedCount:0,interfaces:[item],changes:[item]}:{}})}) + await page.goto("/route-optimizer") + const grid=page.getByRole("table").filter({hasText:"Speed (dl/ul)"}) + await expect(grid).toContainText("Ping модели") + await expect(grid).toContainText("1ms") + await expect(grid).toContainText("Проба: 0 мс") + await expect(grid).toContainText("Измерение: —") + await expect(grid).toContainText("Трафик интерфейса: 0 Мбит/с") +})