diff --git a/components/data-grids/route-optimizer-full-routes-data-grid.tsx b/components/data-grids/route-optimizer-full-routes-data-grid.tsx
index 60cfb5d3..d306c9ca 100644
--- a/components/data-grids/route-optimizer-full-routes-data-grid.tsx
+++ b/components/data-grids/route-optimizer-full-routes-data-grid.tsx
@@ -108,7 +108,7 @@ function RouteOptimizerFullRoutesDataGrid({
{r.jh.label}
- {r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}
+ Модель: {r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}
@@ -130,7 +130,7 @@ function RouteOptimizerFullRoutesDataGrid({
({r.exit.site})
- {r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}
+ Модель: {r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}
)
@@ -141,7 +141,7 @@ function RouteOptimizerFullRoutesDataGrid({
id: "totalPing",
accessorFn: (row) => row.hw.pingMs + row.je.pingMs,
header: ({ column }) => (
-
+
),
cell: ({ row }) => {
const totalPing = row.original.hw.pingMs + row.original.je.pingMs
@@ -161,7 +161,7 @@ function RouteOptimizerFullRoutesDataGrid({
)
},
meta: {
- headerTitle: "Ping (итого)",
+ headerTitle: "Ping модели",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
@@ -231,7 +231,7 @@ function RouteOptimizerFullRoutesDataGrid({
id: "confidence",
accessorKey: "confidence",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
@@ -239,7 +239,7 @@ function RouteOptimizerFullRoutesDataGrid({
),
meta: {
- headerTitle: "Conf.",
+ headerTitle: "Категория модели",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
},
diff --git a/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx b/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx
index e5df92f8..1b15f9ad 100644
--- a/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx
+++ b/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx
@@ -129,21 +129,24 @@ function RouteOptimizerWanMatrixDataGrid({
- {leg.pingMs} мс
+ {leg.observed ? (leg.observed.pingMs ?? "—") : leg.pingMs} мс
- ↓{leg.dlMbps} ↑{leg.ulMbps}
+ ↓{leg.observed ? (leg.observed.dlMbps ?? "—") : leg.dlMbps} ↑{leg.observed ? (leg.observed.ulMbps ?? "—") : leg.ulMbps}
+ {leg.observed &&
+ {leg.observed.probeId ? <>Снимок пробы · Ping: {leg.observed.pingAt ? : "дата неизвестна"}. Скорость: дата неизвестна.> : "Измерения отсутствуют"}
+ }
- score {leg.score}
- {leg.loss > 0 && }
+ модель {leg.score}
+ {(leg.observed ? (leg.observed.lossPct ?? 0) : leg.loss) > 0 && }
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index b71ba88f..2e235211 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.38; preview явно расчётный, далее provenance полей |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.39; исходные WAN/JH метрики отделены от модели, далее backend OSPF provenance |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 10.39 — исходные метрики цепочек оптимизатора (2026-10-01)
+
+- Live WAN–JH/JH–Exit сохраняют observed record отдельно от model полей. Исходные 0, дроби, null и дата пробы не округляются/не ограничиваются WAN cap. Расчётные поля и ranking остаются прежними.
+- Матрица показывает исходные измерения или «—»; без пробы не показывает вычисленный Ping/скорость как измерение. Дата Ping видима, время скорости неизвестно (API его не передаёт), давние пробы не названы текущими. Полные цепочки подписаны как модель, confidence — категория модели. Моки сохраняют отдельный демонстрационный режим.
+- Проверки: 2 unit (нули/дроби/null/давняя дата/disabled/missing, сохранение model clamp/score), 9 E2E (2 новых measured/unknown плюс таблицы/симуляция), TS/lint прошли. ReUI audit consumer-композиции без vendor/theme изменений.
+- Далее backend OSPF observed provenance и строгий контракт. Снимки 10.34 пока не обновлены; визуальную приёмку повторить после backend блока. RouterOS/БД операции по-прежнему не проверены.
+
## Блок 10.38 — пояснение метрик OSPF preview (2026-09-30)
- Рядом с устройством preview явно сообщает: Ping/скорость могут включать расчётные значения и не подтверждают текущее качество соединения. Значения, ranking и write policy не менялись. Это обозначение ограничения, а не завершённая provenance-модель.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index 750c6832..1a68cabe 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -204,3 +204,7 @@ server сохраняет выбор/reload/history без потери друг
## 10.38. Смысл метрик preview
Короткое пояснение расчётных Ping/скорости, без изменения ranking/write. Lint и 14 preview E2E прошли. Далее provenance полей с сохранением observed 0/null; снимки 10.34 ещё без нового текста. Оптимизатор/этап 10 целиком не приняты.
+
+## 10.39. Observed метрики WAN–JH/JH–Exit
+
+Исходные значения/дата пробы отдельно от неизменной модели. Матрица не подменяет unknown моделью, полные цепочки явно расчётные. 2 unit, 9 E2E, TS/lint и ReUI audit прошли. Далее backend OSPF provenance; screenshots ещё требуют обновления.
diff --git a/lib/route-optimizer-data.ts b/lib/route-optimizer-data.ts
index 5bb0d3e8..bb5728ee 100644
--- a/lib/route-optimizer-data.ts
+++ b/lib/route-optimizer-data.ts
@@ -43,7 +43,21 @@ export interface ExitNode {
ip: string
}
+export interface OptimizerObservedMetrics {
+ probeId: string | null
+ pingMs: number | null
+ lossPct: number | null
+ dlMbps: number | null
+ ulMbps: number | null
+ pingAt: string | null
+}
+
+export function readOptimizerObservedMetrics(probe?: RouteOptimizerSpeedProbe): OptimizerObservedMetrics {
+ return {probeId:probe?.id ?? null,pingMs:probe?.lastPingRttMs ?? null,lossPct:probe?.lastPingLossPct ?? null,dlMbps:probe?.lastTxAvgMbps ?? null,ulMbps:probe?.lastRxAvgMbps ?? null,pingAt:probe?.lastPingAt ?? null}
+}
+
export interface WanJhLeg {
+ observed?: OptimizerObservedMetrics
wanId: string
jhId: string
pingMs: number
@@ -54,6 +68,7 @@ export interface WanJhLeg {
}
export interface JhExLeg {
+ observed?: OptimizerObservedMetrics
jhId: string
exitId: string
pingMs: number
@@ -464,6 +479,7 @@ export function buildLiveOptimizerData(
? Math.max(0, Math.min(100, Math.round(probe.lastPingLossPct)))
: (!homeOnline || !jhOnline ? 100 : 0)
wanJhLegs.push({
+ observed: readOptimizerObservedMetrics(probe),
wanId: wan.id,
jhId: jh.id,
pingMs: ping,
@@ -494,6 +510,7 @@ export function buildLiveOptimizerData(
? Math.max(1, Math.round(probe.lastRxAvgMbps))
: modelBw.ul
jhExMap.set(`${jh.id}::${ex.id}`, {
+ observed: readOptimizerObservedMetrics(probe),
jhId: jh.id,
exitId: ex.id,
pingMs: ping,
diff --git a/lib/route-optimizer-observed.test.ts b/lib/route-optimizer-observed.test.ts
new file mode 100644
index 00000000..c61fcc60
--- /dev/null
+++ b/lib/route-optimizer-observed.test.ts
@@ -0,0 +1,23 @@
+import {test} from "node:test"
+import assert from "node:assert/strict"
+import {buildLiveOptimizerData,DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,readOptimizerObservedMetrics,type OptimizerApiServer,type RouteOptimizerSpeedProbe,calcRouteScore} from "./route-optimizer-data"
+const rows:OptimizerApiServer[]=(["home-router","jump-host","exit-node"] as const).map((type,i)=>({id:i+1,name:"Router"+i,host:"192.0.2."+i,country:"RU",site:"TEST",model:"CHR",type,enabled:true,status:"online",latency:10,...(i===0?{wanUplinks:[{id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"198.51.100.1",maxDl:100,maxUl:100}]}:{})}))
+const probe:RouteOptimizerSpeedProbe={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether2",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:150.25,lastRxAvgMbps:null,lastPingAt:"2020-01-01T00:00:00Z"}
+test("observed raw zero, decimal, null and old receipt stay separate from model clamp",()=>{
+ const result=buildLiveOptimizerData(rows,[],DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,[probe,{...probe,id:"e",srcServerId:"2",dstServerId:"3",lastTxAvgMbps:0}])
+ const leg=result.homes[0].wanJhLegs[0],exit=result.homes[0].fullRoutes[0].je
+ assert.equal(leg.pingMs,1);assert.equal(leg.dlMbps,100)
+ assert.equal(leg.observed?.pingMs,0);assert.equal(leg.observed?.dlMbps,150.25);assert.equal(leg.observed?.ulMbps,null)
+ assert.equal(leg.observed?.lossPct,0);assert.equal(leg.observed?.pingAt,"2020-01-01T00:00:00Z")
+ assert.equal(leg.score,calcRouteScore(leg.pingMs,leg.dlMbps,leg.ulMbps,60))
+ assert.equal(exit.pingMs,1);assert.equal(exit.observed?.pingMs,0);assert.equal(exit.observed?.dlMbps,0)
+})
+test("missing and disabled probe leave observed values unknown while model stays available",()=>{
+ for(const probes of [[],[{...probe,enabled:false}]]){
+ const result=buildLiveOptimizerData(rows,[],DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,probes)
+ const leg=result.homes[0].wanJhLegs[0]
+ assert.deepEqual(leg.observed,readOptimizerObservedMetrics())
+ assert.ok(leg.pingMs>0);assert.ok(leg.dlMbps>0)
+ assert.equal(result.homes[0].fullRoutes[0].je.observed?.probeId,null)
+ }
+})
diff --git a/tests/e2e/route-optimizer-observed.spec.ts b/tests/e2e/route-optimizer-observed.spec.ts
new file mode 100644
index 00000000..089f5b07
--- /dev/null
+++ b/tests/e2e/route-optimizer-observed.spec.ts
@@ -0,0 +1,15 @@
+import {test,expect} from "@playwright/test"
+for(const hasProbe of [true,false])test("Оптимизатор: исходные измерения без clamp/"+hasProbe,async({page})=>{
+ await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
+ const probe={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether2",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:150.25,lastRxAvgMbps:null,lastPingAt:"2020-01-01T00:00:00Z"}
+ 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"?["home-router","jump-host","exit-node"].map((type,i)=>({id:i+1,name:"Router"+i,host:"192.0.2."+i,type,country:"RU",enabled:true,status:"online",latency:10,...(i===0?{wanUplinks:[{id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"198.51.100.1",maxDl:100,maxUl:100}]}:{})})):p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:hasProbe?[probe]:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:p.endsWith("/preview")?{serverId:1,serverName:"Router0",pingWeight:60,interfacesTotal:0,changedCount:0,unchangedCount:0,interfaces:[],changes:[]}:{}})})
+ await page.goto("/route-optimizer")
+ const matrix=page.getByRole("table").filter({hasText:"WAN-аплинк"})
+ await expect(matrix.locator("tr[data-row-id]")).toHaveCount(1)
+ await expect(matrix).toContainText(hasProbe?"0 мс":"— мс")
+ await expect(matrix).toContainText(hasProbe?"↓150.25 ↑—":"↓— ↑—")
+ await expect(matrix).toContainText(hasProbe?"Скорость: дата неизвестна":"Измерения отсутствуют")
+ if(hasProbe)await expect(matrix.locator("time")).toHaveAttribute("datetime","2020-01-01T00:00:00Z")
+ await page.getByRole("button",{name:"Маршруты (1)",exact:true}).click()
+ await expect(page.getByRole("table").filter({hasText:"# Маршрут"})).toContainText("Ping модели")
+})