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 }) =>
{describeObserved(row.original.observed?.ping,"мс")}