From 4ba7f82ccf68fb2c49a9a7dbd0b374d9f354f8a1 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 7 Oct 2026 13:44:36 +0700 Subject: [PATCH] =?UTF-8?q?fix(optimizer):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE=20=D1=81=D0=BE=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20DL=20=D0=B8=20UL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 2 +- docs/ux-ui-stage-12.md | 9 ++++++ lib/route-optimizer-data.ts | 32 ++++++++++++++-------- lib/route-optimizer-observed.test.ts | 28 +++++++++++++++++-- tests/e2e/route-optimizer-observed.spec.ts | 18 +++++++++++- 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 2504ac2f..d58f257a 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-07: 12.3.70 аудит observed сохранён: TX/RX≠DL/UL, WAN speed binding не подтверждён, producer missing→0 и unvalidated terminal status. Следующий12.3.71: Home/JH forward RX→DL, TX→UL, reverse Exit/JH с разворотом; null/zero/asymmetric limits/tests. Далее72scope labels,73nullable producer,74terminal validation. Команды и права не менять; внешняя12.5 отдельно. +Актуальное продолжение 2026-10-07: 12.3.71 завершён: RX→DL/TX→UL для client near Home, reverse Exit/JH развернут,model WAN limits корректны;15pure+51main+6observed/type/lint/build. Следующий12.3.72: явно показать device-pair speed scope и модельное сопоставление WAN; backend73nullable parser/74terminal confirmation/75OSPF direction остаются. Команды/права не менять,12.5 отдельно. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 549a0871..27625ae1 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-07: 12.3.70 аудит observed сохранён: TX/RX≠DL/UL, WAN speed binding не подтверждён, producer missing→0 и unvalidated terminal status. Следующий12.3.71: Home/JH forward RX→DL, TX→UL, reverse Exit/JH с разворотом; null/zero/asymmetric limits/tests. Далее72scope labels,73nullable producer,74terminal validation. Команды и права не менять; внешняя12.5 отдельно. +Актуальное продолжение 2026-10-07: 12.3.71 завершён: RX→DL/TX→UL для client near Home, reverse Exit/JH развернут,model WAN limits корректны;15pure+51main+6observed/type/lint/build. Следующий12.3.72: явно показать device-pair speed scope и модельное сопоставление WAN; backend73nullable parser/74terminal confirmation/75OSPF direction остаются. Команды/права не менять,12.5 отдельно. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index f8c58fee..e6013eae 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -790,3 +790,12 @@ CPU final:1000routes13.3ms;100000(10×100×100)49.7ms;100000(100×10×100)44.0ms 2026-10-07. Код сопоставлен с первичной RouterOS bandwidth-test документацией. Подтверждены неверное TX→DL, неподтверждённая связь btest с srcInterface/WAN, missing→0/explicit0→mean и отсутствие проверки terminal status перед success write. Это новые ограничения, которые ранее проверенные mock snapshots не доказывали. Отдельный протокол/таблица/схема: docs/ux-ui/optimizer-observed-audit-2026-10-07.md. Только read-only audit/docs, без DB/RouterOS/сетевых операций. Дальше71direction mapping,72scope labels,73nullable rate parser/producer/DTO,74terminal confirmation. Фактические команды, права и routing rules не меняются этой серией; real12.5 отдельно. Исторические0 не переписывать автоматически. + + +## 12.3.71 — направление измерений DL/UL + +2026-10-07. Для Home→JH RX клиентского bandwidth-test отображается как DL к Home, TX как UL от Home. JH→Exit использует тот же смысл; для reverse Exit→JH клиент на дальней стороне, поэтому TX→DL/RX→UL. Raw getter сохраняет nullable/zero/decimal и исходную дату; модельные maxDl/maxUl применяются к правильной стороне. Команды RouterOS, параметры теста, подбор probe IDs и правила операций не менялись. Ввод/трактовка скоростных данных исправлены по источнику srcClient и RouterOS CLI docs (ссылка в observed audit). + +15pure tests passed:1230 assignment oracle fixtures, source ownership/ties/noWAN, новые asymmetric limits и forward/reverse/zero/null.51main browser и6 observed повторных (включая2новых direction/asymmetric сценария) прошли; TypeScript до dev, targeted lint и production build успешны. Старое ожидание ↓TX исправлено на ↑TX, а не ослаблено. Historical9 optimizer PNG восстановлены. Полная main/context матрица последнего общего прогона относится к прежним HEAD; этот delta проверен профильными сценариями. + +Наблюдения по-прежнему не доказывают конкретный WAN bandwidth-test: следующее72scope labels. Backend producer missing→0/terminal status и OSPF metrics/route builder TX→DL требуют73–75. OSPF traffic fallback уже используетRX→DL, измеренная дата speed-probe намеренноnull и не требует изменения. Real12.5 отдельно. diff --git a/lib/route-optimizer-data.ts b/lib/route-optimizer-data.ts index b4e38957..5fdb2934 100644 --- a/lib/route-optimizer-data.ts +++ b/lib/route-optimizer-data.ts @@ -52,8 +52,14 @@ export interface OptimizerObservedMetrics { 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} +/** DL points toward Home, UL away from Home; TX/RX belong to the btest client. */ +export function readOptimizerObservedMetrics(probe?: RouteOptimizerSpeedProbe, clientOnFarSide = false): OptimizerObservedMetrics { + return { + probeId:probe?.id ?? null,pingMs:probe?.lastPingRttMs ?? null,lossPct:probe?.lastPingLossPct ?? null, + dlMbps:(clientOnFarSide ? probe?.lastTxAvgMbps : probe?.lastRxAvgMbps) ?? null, + ulMbps:(clientOnFarSide ? probe?.lastRxAvgMbps : probe?.lastTxAvgMbps) ?? null, + pingAt:probe?.lastPingAt ?? null, + } } export interface WanJhLeg { @@ -485,22 +491,23 @@ export function buildLiveOptimizerData( const jhRow = byId.get(jh.id) const jhOnline = jhRow?.status === "online" const probe = assignedByWanId.get(wan.id) + const observed = readOptimizerObservedMetrics(probe) const modelPing = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline) const ping = probe?.lastPingRttMs != null ? Math.max(1, Math.round(probe.lastPingRttMs)) : modelPing const modelBw = legBandwidthMbps(ping, wan.maxDl, wan.maxUl) - const dl = probe?.lastTxAvgMbps != null - ? Math.max(1, Math.min(wan.maxDl, Math.round(probe.lastTxAvgMbps))) + const dl = observed.dlMbps !== null + ? Math.max(1, Math.min(wan.maxDl, Math.round(observed.dlMbps))) : modelBw.dl - const ul = probe?.lastRxAvgMbps != null - ? Math.max(1, Math.min(wan.maxUl, Math.round(probe.lastRxAvgMbps))) + const ul = observed.ulMbps !== null + ? Math.max(1, Math.min(wan.maxUl, Math.round(observed.ulMbps))) : modelBw.ul const loss = probe?.lastPingLossPct != null ? Math.max(0, Math.min(100, Math.round(probe.lastPingLossPct))) : (!homeOnline || !jhOnline ? 100 : 0) wanJhLegs.push({ - observed: readOptimizerObservedMetrics(probe), + observed, wanId: wan.id, jhId: jh.id, pingMs: ping, @@ -528,19 +535,20 @@ export function buildLiveOptimizerData( const exRow = byId.get(ex.id) const ok = jhRow?.status === "online" && exRow?.status === "online" const probe = pickProbeForJhExit(jh.id, ex.id) + const observed = readOptimizerObservedMetrics(probe, probe?.srcServerId === ex.id) const modelPing = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok) const ping = probe?.lastPingRttMs != null ? Math.max(1, Math.round(probe.lastPingRttMs)) : modelPing const modelBw = legBandwidthMbps(ping, 1000, 1000) - const dl = probe?.lastTxAvgMbps != null - ? Math.max(1, Math.round(probe.lastTxAvgMbps)) + const dl = observed.dlMbps !== null + ? Math.max(1, Math.round(observed.dlMbps)) : modelBw.dl - const ul = probe?.lastRxAvgMbps != null - ? Math.max(1, Math.round(probe.lastRxAvgMbps)) + const ul = observed.ulMbps !== null + ? Math.max(1, Math.round(observed.ulMbps)) : modelBw.ul jhExMap.set(`${jh.id}::${ex.id}`, { - observed: readOptimizerObservedMetrics(probe), + observed, jhId: jh.id, exitId: ex.id, pingMs: ping, diff --git a/lib/route-optimizer-observed.test.ts b/lib/route-optimizer-observed.test.ts index f7d439fa..4823535d 100644 --- a/lib/route-optimizer-observed.test.ts +++ b/lib/route-optimizer-observed.test.ts @@ -7,10 +7,10 @@ test("observed raw zero, decimal, null and old receipt stay separate from model 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?.pingMs,0);assert.equal(leg.observed?.ulMbps,150.25);assert.equal(leg.observed?.dlMbps,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) + assert.equal(exit.pingMs,1);assert.equal(exit.observed?.pingMs,0);assert.equal(exit.observed?.ulMbps,0) }) test("missing and disabled probe leave observed values unknown while model stays available",()=>{ for(const probes of [[],[{...probe,enabled:false}]]){ @@ -50,3 +50,27 @@ test("Community catalog retains all 1000 source rules without inventing indices" assert.equal(recs.length,1000);assert.equal(recs[999].community,'65001:1000') assert.ok(recs.every(rec=>rec.current===null&&rec.recommended===null&&!rec.shouldSwitch)) }) + + +test("forward and reversed peer tests keep DL toward Home and UL away, with asymmetric WAN limits",()=>{ + const inventory=rows.map((row,i)=>i===0?{...row,wanUplinks:[{id:'w',name:'WAN',iface:'ether1',isp:'ISP',ip:'198.51.100.1',maxDl:100,maxUl:20}]}:row) + const home={...probe,lastPingRttMs:5,lastTxAvgMbps:30,lastRxAvgMbps:80} + const forward={...probe,id:'peer',srcServerId:'2',dstServerId:'3',srcInterface:'',lastPingRttMs:5,lastTxAvgMbps:40,lastRxAvgMbps:90} + const reverse={...forward,srcServerId:'3',dstServerId:'2',lastTxAvgMbps:90,lastRxAvgMbps:40} + for(const peer of [forward,reverse]){ + const entry=buildLiveOptimizerData(inventory,[],DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,[home,peer]).homes[0],route=entry.fullRoutes[0] + assert.equal(route.hw.observed?.dlMbps,80);assert.equal(route.hw.observed?.ulMbps,30) + assert.equal(route.hw.dlMbps,80);assert.equal(route.hw.ulMbps,20) + assert.equal(route.je.observed?.dlMbps,90);assert.equal(route.je.observed?.ulMbps,40) + assert.equal(route.je.dlMbps,90);assert.equal(route.je.ulMbps,40) + assert.equal(route.score,calcRouteScore(10,80,20,60)) + } +}) + +test("reversed probe keeps measured zero and missing rate distinct",()=>{ + const reversed={...probe,lastTxAvgMbps:0,lastRxAvgMbps:null} + assert.equal(readOptimizerObservedMetrics(reversed,true).dlMbps,0) + assert.equal(readOptimizerObservedMetrics(reversed,true).ulMbps,null) + assert.equal(readOptimizerObservedMetrics(reversed).dlMbps,null) + assert.equal(readOptimizerObservedMetrics(reversed).ulMbps,0) +}) diff --git a/tests/e2e/route-optimizer-observed.spec.ts b/tests/e2e/route-optimizer-observed.spec.ts index 7b3770da..c368c118 100644 --- a/tests/e2e/route-optimizer-observed.spec.ts +++ b/tests/e2e/route-optimizer-observed.spec.ts @@ -7,7 +7,7 @@ for(const hasProbe of [true,false])test("Оптимизатор: исходны 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?"↓— ↑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() @@ -36,3 +36,19 @@ test("Оптимизатор: WAN не подставляется из демо" await expect(page.getByRole("table").filter({hasText:"WAN-аплинк"}).locator("tr[data-row-id]")).toHaveCount(0) await expect(page.getByRole("link",{name:"Настроить сервер",exact:true})).toHaveAttribute("href","/servers?server=1") }) +for(const reversed of [false,true])test("Оптимизатор: направление peer и асимметричные лимиты/"+reversed,async({page})=>{ + const common={host:"192.0.2.1",country:"RU",enabled:true,status:"online",latency:10} + const devices=[{...common,id:1,name:"Home",type:"home-router",wanUplinks:[{id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"198.51.100.1",maxDl:100,maxUl:20}]},{...common,id:2,name:"Jump",type:"jump-host"},{...common,id:3,name:"Exit",type:"exit-node"}] + const base={srcInterface:"",dstInterface:"",enabled:true,lastPingRttMs:5,lastPingLossPct:0,lastPingAt:"2026-10-07T00:00:00Z"} + const probes=[{...base,id:"home",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",lastTxAvgMbps:30,lastRxAvgMbps:80},{...base,id:"peer",srcServerId:reversed?"3":"2",dstServerId:reversed?"2":"3",lastTxAvgMbps:reversed?90:40,lastRxAvgMbps:reversed?40:90}] + const writes:string[]=[] + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(r.request().method()!=="GET"&&!path.endsWith("/preview"))writes.push(path);return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/servers"?devices:path==="/api/filters/rules"?{rulesets:[]}:path==="/api/uptime/speed-probes"?{probes}:path==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:path.endsWith("/preview")?{serverId:1,serverName:"Home",pingWeight:60,interfacesTotal:0,changedCount:0,unchangedCount:0,interfaces:[],changes:[]}: {}})}) + await page.goto("/route-optimizer") + await expect(page.getByRole("table").filter({hasText:"WAN-аплинк"})).toContainText("↓80 ↑30") + await page.getByRole("button",{name:"Маршруты (1)",exact:true}).click() + const routes=page.getByRole("table").filter({hasText:"# Маршрут"}) + await expect(routes).toContainText("Модель: 5 мс · ↓80 ↑20") + await expect(routes).toContainText("Модель: 5 мс · ↓90 ↑40") + expect(writes).toEqual([]) +})