Preview изменений OSPF cost (до применения)
-
+
{ospfPreviewLoading
? "расчёт…"
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index fd4f1666..e790b43c 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.35; отсутствующий индекс не подменяется нулём, далее URL/source-контекст |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.36; URL устройства OSPF сохраняется, далее source/provenance |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 10.36 — URL устройства оптимизатора (2026-09-30)
+
+- Параметр server восстанавливает OSPF устройство, select обновляет историю, другие параметры/fragment сохраняются. Suspense обеспечивает production render; URL меняет контекст без сброса локальной формы, источник по-прежнему изолирован key(mode, backendUrl).
+- Unknown/disabled/malformed/empty explicit server не заменяется первым доступным: доступное имя отсутствует, alert предлагает выбор; preview/metadata запросы и применение заблокированы до явного выбора. Late preview проверен прежним race-тестом.
+- 22 E2E preview/apply (6 новых URL сценариев), 3 unit, TS/lint и production frontend прошли. Новый unit fixture из 10.35 уточнён site/model/type после TypeScript проверки. Прежний teardown catalog ECONNREFUSED остаётся известным ограничением теста readonly.
+- Далее: source isolation отдельным config, model provenance/fallback отдельно; затем карта/терминал. Вкладки Home/настройки пока не входят в URL. Реальные RouterOS/БД/JWT операции не проверены.
+
## Блок 10.35 — неизвестные индексы Community (2026-09-30)
- Если полной цепочки нет, оба индекса показывают «—» с доступным названием; отсутствующая база не участвует в вычислении отображаемого выигрыша. Переключение не рекомендуется даже при нулевом пороге. Формула ранжирования полных цепочек не менялась.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index 7fa6e6f1..2275c44f 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -192,3 +192,7 @@ Observed selector вместо default cost/disabled; неоднозначные
## 10.35. Отсутствующие индексы Community
Без полной цепочки индексы неизвестны, рекомендации нет даже при пороге 0. 7 E2E, 3 unit, TS/lint прошли; расчёт существующих цепочек прежний. Далее URL/source-контекст.
+
+## 10.36. URL устройства оптимизатора
+
+server сохраняет выбор/reload/history без потери других параметров. Неизвестный/disabled/malformed explicit контекст не подменяется и не запускает preview/metadata; явный select восстанавливает работу. 22 E2E, 3 unit, TS/lint и production frontend прошли. Далее source/provenance; Home tabs URL пока вне блока.
diff --git a/lib/route-optimizer-response.test.ts b/lib/route-optimizer-response.test.ts
index 5fb43137..23383082 100644
--- a/lib/route-optimizer-response.test.ts
+++ b/lib/route-optimizer-response.test.ts
@@ -19,7 +19,7 @@ test("optimizer refuses unknown types, duplicate inventory/WAN and invalid metri
import {buildLiveOptimizerData,DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS} from "./route-optimizer-data"
test("optimizer does not recommend an absent chain at a zero threshold",()=>{
- const rows=[{...server,wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"198.51.100.1",maxDl:100,maxUl:100}]}]
+ const rows=[{...server,type:"home-router" as const,site:"TEST",model:"CHR",wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"198.51.100.1",maxDl:100,maxUl:100}]}]
const rules=optimizerRulesSchema.parse({rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Service",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}).rulesets
const result=buildLiveOptimizerData(rows,rules,{...DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,switchThreshold:0})
const recommendation=result.homes[0].commRecs[0]
diff --git a/tests/e2e/route-optimizer-ospf-preview.spec.ts b/tests/e2e/route-optimizer-ospf-preview.spec.ts
index dd4bbb59..eb067d9b 100644
--- a/tests/e2e/route-optimizer-ospf-preview.spec.ts
+++ b/tests/e2e/route-optimizer-ospf-preview.spec.ts
@@ -3,7 +3,7 @@ const iface=(name="ether1")=>({id:"i",interface:name,currentCost:10,optimalCost:
const preview=(id=1,weight=60,name="ether1")=>({serverId:id,serverName:"Роутер "+id,pingWeight:weight,interfacesTotal:1,changedCount:1,unchangedCount:0,interfaces:[iface(name)],changes:[iface(name)]})
async function base(page:Page){
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/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,type:"home-router",country:"RU",enabled:true,status:null})):p==="/api/auth/config"?{required:false}:p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:preview(Number(p.split("/")[3]),r.request().postDataJSON()?.pingWeight??60)})})
+ await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[1,2,3].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,type:"home-router",country:"RU",enabled:id!==3,status:null})):p==="/api/auth/config"?{required:false}:p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:preview(Number(p.split("/")[3]),r.request().postDataJSON()?.pingWeight??60)})})
}
for(const failure of ["http","foreign","weight","count","missing","duplicate"] as const)test("Оптимизатор: OSPF preview блокирует "+failure,async({page})=>{
await base(page);let failing=true
@@ -37,3 +37,29 @@ test("Оптимизатор: поздний preview прежнего устро
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))))
await expect(grid).toContainText("ether2");await expect(grid).not.toContainText("ether1")
})
+
+test("Оптимизатор: URL устройства, reload и история",async({page})=>{
+ await base(page)
+ await page.goto("/route-optimizer?server=2&keep=value")
+ const select=page.getByRole("combobox",{name:"Устройство OSPF",exact:true})
+ await expect(select).toHaveValue("2")
+ await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible()
+ await select.selectOption("1");await expect(page).toHaveURL(/server=1&keep=value/)
+ await expect(select).toHaveValue("1");await page.reload();await expect(select).toHaveValue("1")
+ await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible()
+ await page.goBack();await expect(select).toHaveValue("2")
+ await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible()
+})
+for(const server of ["999","3","abc","01",""])test("Оптимизатор: недоступный URL не выбирает другой сервер "+server,async({page})=>{
+ await base(page);let reads=0
+ await page.route("**/ospf/optimize/preview",r=>{reads++;return r.fulfill({json:preview(Number(new URL(r.request().url()).pathname.split("/")[3]))})})
+ await page.goto("/route-optimizer?server="+server)
+ await expect(page.getByRole("alert").filter({hasText:"Устройство из ссылки"})).toBeVisible()
+ await expect(page.getByRole("combobox",{name:"Устройство OSPF",exact:true})).toHaveValue(server)
+ await expect(page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})).toBeDisabled()
+ await expect(page.getByRole("button",{name:"Обновить preview OSPF",exact:true})).toBeDisabled()
+ expect(reads).toBe(0)
+ await page.getByRole("combobox",{name:"Устройство OSPF",exact:true}).selectOption("2")
+ await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible()
+ await expect(page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})).toBeEnabled()
+})