))}
@@ -176,14 +177,14 @@ function RecursiveRoutesDataGrid({
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
return (
- {sorted.map((ep, idx) => {
- const code = ep.country || inferCountry(ep.gateway)
+ {sorted.map(ep => {
+ const code = ep.country
return (
-
{code ? (
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 8d54c559..613fab75 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.26; URL/source маршрутов, далее observed metadata |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.27; строгий каталог маршрутов, далее layout/keyboard |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 10.27 — строгий каталог рекурсивных маршрутов (2026-09-30)
+
+- Новый /recursive-routes/catalog читает RouterOS напрямую и принимает только полный валидный managed-снимок. Неизвестные обязательные параметры или неподдерживаемая принадлежащая приложению запись дают 503, а не пустой список, который мог бы удалить маршруты при полном применении. Legacy GET сохранён для существующих потребителей Filters.
+- Нули distance/scope/targetScope сохраняются; отсутствующие optional scope остаются null. Редактирование сохраняет disabled. Страна — только явная аннотация, без угадывания по имени шлюза.
+- Gateway catalog сообщает использование шлюза в active/inactive маршрутах или неизвестность; это не проверка доступности шлюза. Основание полей: [RouterOS IP route](https://manual.mikrotik.com/docs/cli-reference/ip/route/).
+- Проверки: 42 основных E2E и повторные 18 data/values (три новых), восемь source E2E, backend parser/HTTP/config-sync/build, TS/lint прошли. Перехват нового вложенного catalog в source тесте исправлен; повторный прогон 8/8. Production frontend (34 страницы) прошёл.
+- Далее 10.28: адаптивность, keyboard и снимки. Реальные RouterOS/DB/JWT и совместимость полного ответа конкретной версии устройства требуют внешней приёмки. Последняя квота: 55% окна, 40% недели использовано. Локальный коммит без push.
+
## Блок 10.26 — URL и source/history рекурсивных маршрутов (2026-09-30)
- server/q/route/table сохранены в URL. Default устройство записывается через replace; выбор/раскрытие используют history, поиск replace. Чужие query-поля сохранены. Missing/invalid/repeated/oversized контекст блокирует срез; route требует table. Отсутствующая группа закрывается отдельно.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index 6dba5b74..7905be29 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -156,3 +156,7 @@ Strict receipts/serverId, общая блокировка save/delete/restore,
## 10.26. URL/history/source
server/q/route/table принадлежат экрану; default replace, выбор/раскрытие history. Missing-device fallback панели запрещён. Source lock сохраняется над device context; late write освобождает lock и запускает чтение текущего среза только соответствующего источника. 38 разных основных + 8 source E2E, 2 unit, TS/lint и production frontend прошли. Далее observed metadata и layout.
+
+## 10.27. Строгий наблюдаемый каталог
+
+Отдельные catalog endpoints сохраняют legacy потребителей. Неполный owned snapshot отклоняется целиком до сохранения; нули, disabled и unset scope сохранены. Gateway active означает использование в маршруте, без заявления о reachability; страна только явная. 42 основных + повторные 18 data/values, 8 source, backend parser/HTTP/config-sync/build и TS/lint прошли. Далее визуальная приёмка. Реальная версия RouterOS требует отдельной проверки.
diff --git a/lib/recursive-route-response.ts b/lib/recursive-route-response.ts
index bbd4bdd6..5ddfa708 100644
--- a/lib/recursive-route-response.ts
+++ b/lib/recursive-route-response.ts
@@ -16,7 +16,7 @@ export const recursiveReceiptSchema = z.object({
}).refine(data => data.pushedRoutes === data.routes.length)
export const recursiveGatewaysSchema = z.object({
serverId: z.number().int().positive(), gateways: z.array(z.object({
- id: z.string().min(1), name: z.string(), ip: z.string().min(1), status: z.enum(["up", "down"]),
+ id: z.string().min(1), name: z.string(), ip: z.string().min(1), status: z.enum(["active", "inactive", "unknown"]),
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length),
})
export type RecursiveRouteRow = z.infer
diff --git a/tests/e2e/config-history.spec.ts b/tests/e2e/config-history.spec.ts
index acb5a8f2..87301197 100644
--- a/tests/e2e/config-history.spec.ts
+++ b/tests/e2e/config-history.spec.ts
@@ -31,8 +31,8 @@ for (const section of ["wireguard", "ipsec", "gre", "firewall", "filters", "recu
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/firewall/all" ? { rules: [], addressLists: [] }
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
- : path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false }
- : path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
+ : path === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [], live: true, stale: false }
+ : path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {}
return route.fulfill({ json })
})
@@ -95,8 +95,8 @@ for (const section of ["ipsec", "gre", "firewall", "filters", "recursive-routes"
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/firewall/all" ? { rules: [], addressLists: [] }
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
- : path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false }
- : path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
+ : path === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [], live: true, stale: false }
+ : path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
: path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
})
diff --git a/tests/e2e/page-header.spec.ts b/tests/e2e/page-header.spec.ts
index a75209fe..af658afd 100644
--- a/tests/e2e/page-header.spec.ts
+++ b/tests/e2e/page-header.spec.ts
@@ -8,8 +8,8 @@ for (const width of [320, 390, 1024, 1440]) {
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
- return route.fulfill({ json: path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false }
- : path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
+ return route.fulfill({ json: path === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [], live: true, stale: false }
+ : path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }]
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} })
diff --git a/tests/e2e/recursive-routes-context.spec.ts b/tests/e2e/recursive-routes-context.spec.ts
index 3cb7303e..fb7f6351 100644
--- a/tests/e2e/recursive-routes-context.spec.ts
+++ b/tests/e2e/recursive-routes-context.spec.ts
@@ -6,14 +6,14 @@ for(const change of ["backend","mode"])for(const operation of ["read","write"])f
let newReads=0
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}))
await page.route("**/api/**",r=>{
- const u=new URL(r.request().url()),p=u.pathname;if(p==="/api/recursive-routes"&&u.port==="8101")newReads++
- return r.fulfill({headers,json:p==="/api/servers"?[device]:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes"?{serverId:1,routes:[row(u.port==="8101"?"Новый снимок":"Старый снимок")],live:true,stale:false}:p==="/api/recursive-routes/gateways"?{serverId:1,gateways:[]}:{} })
+ const u=new URL(r.request().url()),p=u.pathname;if(p==="/api/recursive-routes/catalog"&&u.port==="8101")newReads++
+ return r.fulfill({headers,json:p==="/api/servers"?[device]:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes/catalog"?{serverId:1,routes:[row(u.port==="8101"?"Новый снимок":"Старый снимок")],live:true,stale:false}:p==="/api/recursive-routes/gateways/catalog"?{serverId:1,gateways:[]}:{} })
})
await page.goto("/settings");await page.getByRole("link",{name:/^Рекурсивные маршруты/}).click()
await expect(page.locator("tr[data-row-id]")).toContainText("Старый снимок")
let started=false,release!:()=>void;const gate=new Promise(resolve=>{release=resolve})
- await page.route("**/api/recursive-routes*",async r=>{
- const u=new URL(r.request().url());if(u.port!=="8000"||u.pathname!=="/api/recursive-routes"||r.request().method()!==(operation==="read"?"GET":"PUT"))return r.fallback()
+ await page.route("**/api/recursive-routes**",async r=>{
+ const u=new URL(r.request().url());if(u.port!=="8000"||u.pathname!==(operation==="read"?"/api/recursive-routes/catalog":"/api/recursive-routes")||r.request().method()!==(operation==="read"?"GET":"PUT"))return r.fallback()
started=true;await gate
return r.fulfill(fails?{headers,status:503,json:{error:"Старая ошибка маршрутов"}}:{headers,json:operation==="read"?{serverId:1,routes:[],live:true,stale:false}:{ok:true,serverId:1,routes:r.request().postDataJSON().routes,pushedRoutes:r.request().postDataJSON().routes.length}})
})
@@ -29,7 +29,7 @@ for(const change of ["backend","mode"])for(const operation of ["read","write"])f
await page.getByRole("link",{name:/^Рекурсивные маршруты/}).click()
if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый снимок")
else await expect(page.getByText("Раздел работает в режиме",{exact:false})).toBeVisible()
- const before=newReads,response=page.waitForResponse(r=>new URL(r.url()).port==="8000"&&new URL(r.url()).pathname==="/api/recursive-routes"&&r.request().method()===(operation==="read"?"GET":"PUT"))
+ const before=newReads,response=page.waitForResponse(r=>new URL(r.url()).port==="8000"&&new URL(r.url()).pathname===(operation==="read"?"/api/recursive-routes/catalog":"/api/recursive-routes")&&r.request().method()===(operation==="read"?"GET":"PUT"))
release();await(await response).finished()
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))))
if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый снимок")
diff --git a/tests/e2e/recursive-routes-data.spec.ts b/tests/e2e/recursive-routes-data.spec.ts
index ce5bebb0..676d0880 100644
--- a/tests/e2e/recursive-routes-data.spec.ts
+++ b/tests/e2e/recursive-routes-data.spec.ts
@@ -20,8 +20,8 @@ for (const result of ["success", "http", "false", "missing", "foreign", "count"]
saved = body.routes; return r.fulfill({ json: { ok: true, serverId: 1, routes: saved, pushedRoutes: saved.length } })
}
return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false }
- : p === "/api/recursive-routes" ? { serverId: Number(u.searchParams.get("serverId")), routes: saved, live: true, stale: false }
- : p === "/api/recursive-routes/gateways" ? { serverId: Number(u.searchParams.get("serverId")), gateways: [] } : {} })
+ : p === "/api/recursive-routes/catalog" ? { serverId: Number(u.searchParams.get("serverId")), routes: saved, live: true, stale: false }
+ : p === "/api/recursive-routes/gateways/catalog" ? { serverId: Number(u.searchParams.get("serverId")), gateways: [] } : {} })
})
await page.goto("/recursive-routes")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
@@ -53,13 +53,13 @@ for (const bad of ["http", "foreign", "payload", "duplicate", "stale"] as const)
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const p = new URL(r.request().url()).pathname
- if (p === "/api/recursive-routes") {
+ if (p === "/api/recursive-routes/catalog") {
if (phase !== 1 && bad === "http") return r.fulfill({ status: 503, json: { error: "Чтение недоступно тест" } })
const valid = { serverId: 1, routes: phase === 0 ? [] : [routeRow()], live: true, stale: false }
return r.fulfill({ json: phase === 1 ? valid : bad === "foreign" ? { ...valid, serverId: 2 } : bad === "payload" ? { ...valid, routes: [{}] }
: bad === "duplicate" ? { ...valid, routes: [routeRow(), routeRow()] } : { ...valid, live: false, stale: true, error: "Кэш тест" } })
}
- return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : {} })
+ return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] } : {} })
})
await page.goto("/recursive-routes")
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled()
@@ -77,9 +77,9 @@ test("Рекурсивные маршруты: чтение прежнего у
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async r => {
const u = new URL(r.request().url()), p = u.pathname, id = Number(u.searchParams.get("serverId"))
- if (p === "/api/recursive-routes") { if (id === 1) { requested = true; await gate }
+ if (p === "/api/recursive-routes/catalog") { if (id === 1) { requested = true; await gate }
return r.fulfill({ json: { serverId: id, routes: [routeRow("r" + id, "10.0." + id + ".0/24")], live: true, stale: false } }) }
- return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways" ? { serverId: id, gateways: [] } : {} })
+ return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways/catalog" ? { serverId: id, gateways: [] } : {} })
})
await page.goto("/recursive-routes"); await expect.poll(() => requested).toBe(true)
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
@@ -88,12 +88,12 @@ test("Рекурсивные маршруты: чтение прежнего у
await expect(page.locator("tr[data-row-id]")).not.toContainText("10.0.1.0/24")
})
test("Рекурсивные маршруты: редактирование поиска сохраняет скрытые endpoints и таблицы", async ({ page }) => {
- let saved: unknown; const rows = [{ ...routeRow(), gateway: "192.0.2.11" }, { ...routeRow("b"), gateway: "192.0.2.22" }, { ...routeRow("c", "10.0.1.0/24", "vpn"), gateway: "192.0.2.33" }]
+ let saved: unknown; const rows = [{ ...routeRow(), gateway: "192.0.2.11" }, { ...routeRow("b"), gateway: "192.0.2.22", disabled: true }, { ...routeRow("c", "10.0.1.0/24", "vpn"), gateway: "192.0.2.33" }]
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const p = new URL(r.request().url()).pathname
if (p === "/api/recursive-routes" && r.request().method() === "PUT") { const next = r.request().postDataJSON().routes; saved = next; return r.fulfill({ json: { ok: true, serverId: 1, routes: next, pushedRoutes: next.length } }) }
- return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes" ? { serverId: 1, routes: rows, live: true, stale: false } : p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : {} })
+ return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/catalog" ? { serverId: 1, routes: rows, live: true, stale: false } : p === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] } : {} })
})
await page.goto("/recursive-routes"); await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
await page.getByRole("textbox", { name: "Поиск маршрутов" }).fill("192.0.2.11")
@@ -102,7 +102,7 @@ test("Рекурсивные маршруты: редактирование по
const sheet = page.getByRole("dialog")
await expect(sheet.getByPlaceholder("1.2.3.4%GW-NAME")).toHaveCount(2)
await sheet.getByRole("button", { name: "Сохранить", exact: true }).click(); await expect(sheet).toHaveCount(0)
- expect(saved).toEqual(expect.arrayContaining([expect.objectContaining({ routingTable: "main", gateway: "192.0.2.11" }), expect.objectContaining({ routingTable: "main", gateway: "192.0.2.22" }), expect.objectContaining({ routingTable: "vpn", gateway: "192.0.2.33" })]))
+ expect(saved).toEqual(expect.arrayContaining([expect.objectContaining({ routingTable: "main", gateway: "192.0.2.11" }), expect.objectContaining({ routingTable: "main", gateway: "192.0.2.22", disabled: true }), expect.objectContaining({ routingTable: "vpn", gateway: "192.0.2.33" })]))
expect(saved).toHaveLength(3)
})
@@ -116,8 +116,8 @@ for (const allowed of [false, true]) test("Рекурсивные маршрут
const p = new URL(r.request().url()).pathname
if (r.request().method() !== "GET") writes++
return r.fulfill({ json: p === "/api/auth/config" ? { required: true } : p === "/api/servers" ? [{ ...servers[0], enabled: !allowed }]
- : p === "/api/recursive-routes" ? { serverId: 1, routes: [routeRow()], live: true, stale: false }
- : p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
+ : p === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [routeRow()], live: true, stale: false }
+ : p === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: p.endsWith("/revisions") ? { revisions: [{ id: "rev", serverId: "1", section: "recursive-routes", source: "apply", fingerprint: "fixture", createdAt: "2026-09-30T00:00:00Z", note: null, itemCount: 1 }] } : {} })
})
await page.goto("/recursive-routes"); await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
diff --git a/tests/e2e/recursive-routes-links.spec.ts b/tests/e2e/recursive-routes-links.spec.ts
index 28f0e6eb..e5eb6553 100644
--- a/tests/e2e/recursive-routes-links.spec.ts
+++ b/tests/e2e/recursive-routes-links.spec.ts
@@ -3,7 +3,7 @@ const devices=[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,coun
const row=(id:number)=>({id:"r"+id,dstAddress:"10.0."+id+".0/24",gateway:"192.0.2."+id,distance:1,scope:30,targetScope:10,routingTable:"main",checkGateway:"ping",comment:"Маршрут "+id,disabled:false,country:"RU"})
async function setup(page:Page) {
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
- await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname,id=Number(u.searchParams.get("serverId"));return r.fulfill({json:p==="/api/servers"?devices:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes"?{serverId:id,routes:[row(id)],live:true,stale:false}:p==="/api/recursive-routes/gateways"?{serverId:id,gateways:[]}:{} })})
+ await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname,id=Number(u.searchParams.get("serverId"));return r.fulfill({json:p==="/api/servers"?devices:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes/catalog"?{serverId:id,routes:[row(id)],live:true,stale:false}:p==="/api/recursive-routes/gateways/catalog"?{serverId:id,gateways:[]}:{} })})
}
test("Маршруты: устройство, поиск и раскрытие переживают reload/history",async({page})=>{
await setup(page);await page.goto("/recursive-routes?server=2&q=10.0.2&route=10.0.2.0%2F24&table=main&keep=x")
@@ -35,7 +35,7 @@ test("Маршруты: отсутствующая группа закрывае
})
for(const fails of [false,true])test("Маршруты: history сохраняет lock до завершения записи "+fails,async({page})=>{
await setup(page);let writes=0,reads=0;let release!:()=>void;const gate=new Promise(resolve=>{release=resolve})
- await page.route("**/api/recursive-routes?**",r=>{reads++;const id=Number(new URL(r.request().url()).searchParams.get("serverId"));return r.fulfill({json:{serverId:id,routes:[row(id)],live:true,stale:false}})})
+ await page.route("**/api/recursive-routes/catalog?**",r=>{reads++;const id=Number(new URL(r.request().url()).searchParams.get("serverId"));return r.fulfill({json:{serverId:id,routes:[row(id)],live:true,stale:false}})})
await page.route("**/api/recursive-routes",async r=>{writes++;await gate;const next=r.request().postDataJSON().routes;return r.fulfill(fails?{status:503,json:{error:"Старая ошибка записи"}}:{json:{ok:true,serverId:1,routes:next,pushedRoutes:next.length}})})
await page.goto("/recursive-routes?server=2")
await page.getByRole("option").filter({hasText:"Роутер 1"}).click()
diff --git a/tests/e2e/recursive-routes-table.spec.ts b/tests/e2e/recursive-routes-table.spec.ts
index 1da05a21..4ce63ad2 100644
--- a/tests/e2e/recursive-routes-table.spec.ts
+++ b/tests/e2e/recursive-routes-table.spec.ts
@@ -11,8 +11,8 @@ test("Рекурсивные маршруты: все группы и раскр
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
- return route.fulfill({ json: path === "/api/recursive-routes" ? { serverId: 1, routes, live: true, stale: false }
- : path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
+ return route.fulfill({ json: path === "/api/recursive-routes/catalog" ? { serverId: 1, routes, live: true, stale: false }
+ : path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }]
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} })
diff --git a/tests/e2e/recursive-routes-values.spec.ts b/tests/e2e/recursive-routes-values.spec.ts
new file mode 100644
index 00000000..2ab8b357
--- /dev/null
+++ b/tests/e2e/recursive-routes-values.spec.ts
@@ -0,0 +1,22 @@
+import {test,expect} from "@playwright/test"
+for(const status of ["active","inactive","unknown"] as const)test("Маршруты: наблюдаемые нули, disabled и использование шлюза "+status,async({page})=>{
+ const item={id:"r",dstAddress:"10.0.0.0/24",gateway:"192.0.2.1%bridge1",distance:0,scope:0,targetScope:0,routingTable:"main",checkGateway:"none",comment:"Тест",disabled:true,country:""}
+ let submitted:unknown
+ await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
+ await page.route("**/api/**",r=>{
+ const p=new URL(r.request().url()).pathname
+ if(p==="/api/recursive-routes"&&r.request().method()==="PUT"){const routes=r.request().postDataJSON().routes;submitted=routes;return r.fulfill({json:{ok:true,serverId:1,routes,pushedRoutes:routes.length}})}
+ return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[{id:1,name:"Роутер 1",host:"192.0.2.1",country:"RU",enabled:true,status:null}]:p==="/api/recursive-routes/catalog"?{serverId:1,routes:[item],live:true,stale:false}:p==="/api/recursive-routes/gateways/catalog"?{serverId:1,gateways:[{id:"gateway",ip:"192.0.2.1",name:"bridge1",status}]}:{}})
+ })
+ await page.goto("/recursive-routes")
+ await page.locator("tr[data-row-id]").click()
+ await expect(page.getByText("distance: 0",{exact:true})).toBeVisible();await expect(page.getByText("scope: 0",{exact:true})).toBeVisible();await expect(page.getByText("Отключён",{exact:true})).toBeVisible()
+ await page.getByRole("button",{name:"Редактировать маршрут 10.0.0.0/24 · main",exact:true}).click()
+ const sheet=page.getByRole("dialog")
+ await expect(sheet.getByRole("spinbutton").nth(0)).toHaveValue("0")
+ await expect(sheet.getByRole("spinbutton").nth(1)).toHaveValue("0")
+ await expect(sheet.getByRole("spinbutton").nth(2)).toHaveValue("0")
+ await expect(sheet.getByRole("button").filter({hasText:status==="active"?"Активный маршрут":status==="inactive"?"Неактивный маршрут":"Статус неизвестен"})).toHaveCount(1)
+ await sheet.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(sheet).toHaveCount(0)
+ expect(submitted).toEqual([expect.objectContaining({distance:0,scope:0,targetScope:0,disabled:true,country:"",checkGateway:"none"})])
+})