fix(ui): адаптировать панели и действия карты сети

This commit is contained in:
Denozordec committed 2026-10-01 03:06:28 +07:00
1 parent 8bc4f22af7
commit 89e48c2424
15 files changed
+337 -270

No files matched your search

File diff suppressed because it is too large. Load diff
+9
View File
@@ -181,3 +181,12 @@ HTTP-фикстуры; локальная приёмка: 43 основных +
| Community · [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Снимок](ux-ui/screens/route-optimizer-community-desktop.png) | [390 px](ux-ui/screens/route-optimizer-community-mobile.png), [320 px](ux-ui/screens/route-optimizer-community-narrow.png) | Устройство → базовый расчёт → модельная рекомендация → закрепление |
47 основных + 8 source E2E, backend optimizer/live-maps, production frontend/backend прошли. Исходные значения отдельно от модели; Community apply недоступен, демо только симуляция. HTTP/DI фикстуры; реальный RouterOS/JWT/БД остаётся внешней приёмкой. Далее карта/терминал; ограничения и точное продолжение — в журнале.
## Карта сети — промежуточные экраны 2026-10-01
| Экран / референс | ПК | Телефон | Схема |
| --- | --- | --- | --- |
| Оболочка · [Frame](https://reui.io/components/frame?ref=mcp) / [API](https://reui.io/docs/components/base/frame?ref=mcp) | [Обзор](ux-ui/screens/network-map-overview-desktop.png) | [390 px](ux-ui/screens/network-map-overview-mobile.png), [320 px](ux-ui/screens/network-map-overview-narrow.png) | Источник → свежесть → слои/фильтры → карта |
| Детали · Frame / [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Детали](ux-ui/screens/network-map-detail-desktop.png) | [390 px](ux-ui/screens/network-map-detail-mobile.png), [320 px](ux-ui/screens/network-map-detail-narrow.png) | Устройство → фактические поля → действия → возврат |
Оболочка и клавиатурные действия проверены (35 + 12 E2E). Масштаб малой топологии требует следующего блока; это промежуточные снимки, не итоговая приёмка карты.
+10 -1
View File
@@ -26,11 +26,20 @@
| 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.51; оптимизатор принят локально, карта сети в работе |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.52; оптимизатор принят локально, карта сети в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.52 — Frame, доступные действия и детали карты (2026-10-01)
- Установленный ReUI Frame объединяет toolbar, SVG и детали с parent-owned высотой. На ПК детали справа; на телефоне занимают рабочую область, закрытие возвращает карту. Выбор устройства доступен вне SVG, длинные имена переносятся по словам, отсутствует горизонтальный overflow на 1440/390/320 px.
- Действия устройства используют стандартный Base UI Dialog с действительными ссылками и обработкой отказа clipboard. Слои — Popover; Escape возвращает фокус и сохраняет server URL. Узлы устройств/WAN/сервисов и метрики GRE/Flow доступны Enter/Space; кнопки названы, декоративные иконки скрыты от screen reader.
- SVG использует semantic tokens; reduced motion отключает animateMotion. Экспорт сохраняет CSS переменные/фон и сериализуется XMLSerializer как standalone SVG. Nullable UL не выводится строкой null; неизвестный IPsec явно подписан.
- 35 основных + 12 source E2E, TypeScript и lint прошли; ReUI Frame checklist прочитан. Первые регрессионные тесты выбирали скрытые option после добавления селектора: селекторы ограничены SVG, повтор чистый. 6 снимков проверены визуально.
- Далее 10.53: камера по границам объектов и читаемость малой топологии, pointer/touch и пустые состояния; текущие overview снимки ещё показывают слишком мелкий граф. Production build — после этого блока. Реальные RouterOS/JWT/БД не проверены.
## Блок 10.51 — URL-контекст карты (2026-10-01)
- URL сохраняет server/WAN, status filter, q и режим services/countries. Перезагрузка и история восстанавливают детали/срез; поиск заменяет текущий history entry. Посторонние query/hash сохраняются. Выбор GRE/сервиса пока локальный, pan/zoom/drag также локальные.
+9
View File
@@ -284,3 +284,12 @@ Shared контракт и backend observed исходные значения/д
- 2 URL unit, 11 E2E URL/details (7 URL повторены после исправления тестового encoded-space matcher), TypeScript и lint прошли. В production ещё требуется общий build после адаптивной композиции.
- Далее ReUI Frame, панели и интерактивные SVG controls: keyboard/focus, телефон, reduced motion, скриншоты и локальная приёмка карты.
## Блок 10.52 — Frame, доступные действия и детали карты (2026-10-01)
- Установленный ReUI Frame объединяет toolbar, SVG и детали с parent-owned высотой. На ПК детали справа; на телефоне занимают рабочую область, закрытие возвращает карту. Выбор устройства доступен вне SVG, длинные имена переносятся по словам, отсутствует горизонтальный overflow на 1440/390/320 px.
- Действия устройства используют стандартный Base UI Dialog с действительными ссылками и обработкой отказа clipboard. Слои — Popover; Escape возвращает фокус и сохраняет server URL. Узлы устройств/WAN/сервисов и метрики GRE/Flow доступны Enter/Space; кнопки названы, декоративные иконки скрыты от screen reader.
- SVG использует semantic tokens; reduced motion отключает animateMotion. Экспорт сохраняет CSS переменные/фон и сериализуется XMLSerializer как standalone SVG. Nullable UL не выводится строкой null; неизвестный IPsec явно подписан.
- 35 основных + 12 source E2E, TypeScript и lint прошли; ReUI Frame checklist прочитан. Первые регрессионные тесты выбирали скрытые option после добавления селектора: селекторы ограничены SVG, повтор чистый. 6 снимков проверены визуально.
- Далее 10.53: камера по границам объектов и читаемость малой топологии, pointer/touch и пустые состояния; текущие overview снимки ещё показывают слишком мелкий граф. Production build — после этого блока. Реальные RouterOS/JWT/БД не проверены.
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+3 -3
View File
@@ -11,7 +11,7 @@ for(const change of ["backend","mode"])for(const part of ["snapshot","flow","dns
return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[row(u.port==="8101"?"Новая карта":"Прежняя карта")]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/network/resolve-hosts"?{results:{"router.example":"192.0.2.2"}}:{}})
})
await page.goto("/settings");await page.getByRole("link",{name:/^Карта сети/}).click()
await expect(page.getByText("Прежняя карта",{exact:true}).first()).toBeVisible()
await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Прежняя карта",{exact:true})).toBeVisible()
await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toBeVisible()
const path=part==="snapshot"?"/api/servers":part==="flow"?"/api/traffic/flow/map-hops":"/api/network/resolve-hosts"
await page.route("**/api/**",async r=>{
@@ -24,7 +24,7 @@ for(const change of ["backend","mode"])for(const part of ["snapshot","flow","dns
if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}
else await page.getByRole("button",{name:"Моковые",exact:true}).click()
await page.getByRole("link",{name:/^Карта сети/}).click()
if(change==="backend"){await expect(page.getByText("Новая карта",{exact:true}).first()).toBeVisible();await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toBeVisible()}
if(change==="backend"){await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Новая карта",{exact:true})).toBeVisible();await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toBeVisible()}
else await expect(page.getByText("Демо · mock",{exact:true})).toBeVisible()
const before=newReads;release();await expect.poll(()=>completed).toBe(true)
await page.evaluate(()=>new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))))
@@ -32,6 +32,6 @@ for(const change of ["backend","mode"])for(const part of ["snapshot","flow","dns
await expect(page.getByText("Поздняя карта",{exact:false})).toHaveCount(0)
await expect(page.getByText("Поздний сервис",{exact:false})).toHaveCount(0)
await expect(page.getByRole("alert").filter({hasText:"DNS не разрешён"})).toHaveCount(0)
if(change==="backend")await expect(page.getByText("Новая карта",{exact:true}).first()).toBeVisible()
if(change==="backend")await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Новая карта",{exact:true})).toBeVisible()
expect(newReads).toBe(before)
})
+6 -6
View File
@@ -9,7 +9,7 @@ for(const part of ["servers","gre","probes"] as const)for(const malformed of [fa
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка карты"})).toBeVisible()
await expect(page.getByText("Данные карты неизвестны",{exact:true})).toBeVisible()
failing=false;await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByText("Роутер",{exact:true}).first()).toBeVisible()
await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Роутер",{exact:true})).toBeVisible()
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка карты"})).toHaveCount(0)
})
for(const part of ["servers","gre","probes"] as const)test("Карта: частичный отказ сохраняет снимок "+part,async({page})=>{
@@ -17,10 +17,10 @@ for(const part of ["servers","gre","probes"] as const)test("Карта: част
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(failing&&p===paths[part]?{status:503,json:{error:"Источник недоступен"}}:{json:body(p,failing?"Новый роутер":"Последний роутер")})})
await page.goto("/network-map")
await expect(page.getByText("Последний роутер",{exact:true}).first()).toBeVisible()
await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Последний роутер",{exact:true})).toBeVisible()
failing=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByRole("alert").filter({hasText:"Последний полный снимок"})).toBeVisible()
await expect(page.getByText("Последний роутер",{exact:true}).first()).toBeVisible()
await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Последний роутер",{exact:true})).toBeVisible()
await expect(page.getByText("Новый роутер",{exact:true})).toHaveCount(0)
})
@@ -30,16 +30,16 @@ test("Карта: неизвестный статус не становится
await page.goto("/network-map")
await expect(page.getByText("1 неизвестно",{exact:true})).toBeVisible()
await expect(page.getByText("0 оффлайн",{exact:true})).toBeVisible()
await page.getByText("Роутер",{exact:true}).first().click()
await page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Роутер",{exact:true}).click()
await expect(page.locator("span").filter({hasText:/^Неизвестно$/}).first()).toBeVisible()
await page.getByRole("button",{name:"Неизвестно",exact:true}).click()
await expect(page.getByText("Роутер",{exact:true}).first()).toBeVisible()
await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Роутер",{exact:true})).toBeVisible()
})
for(const polled of [false,true])test("Карта: реальные и неизвестные ресурсы "+polled,async({page})=>{
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;const data=body(p);return r.fulfill({json:p===paths.servers?[{...(data as object[])[0],...(polled?{cpuLoad:0,totalMemory:1048576,freeMemory:1048576,uptime:"0s",polledAt:"2020-01-01T00:00:00Z"}:{})}]:data})})
await page.goto("/network-map");await page.getByText("Роутер",{exact:true}).first().click()
await page.goto("/network-map");await page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Роутер",{exact:true}).click()
const panel=page.getByText("Ресурсы",{exact:true}).locator("..")
await expect(panel.getByText("История CPU и диск API не передаются.",{exact:false})).toBeVisible()
await expect(panel.locator("svg")).toHaveCount(0)
+2 -2
View File
@@ -19,7 +19,7 @@ test("Карта: модель WAN не утверждает активный м
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
await page.goto("/network-map");await expect(page.getByText("Пунктир WAN→JH — модель связности; маршрут не подтверждён.",{exact:true})).toBeVisible()
await expect(page.locator("svg text").filter({hasText:/^5 мс$/})).toHaveCount(0)
await page.getByText("Домашний",{exact:true}).first().click()
await page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Домашний",{exact:true}).click()
await expect(page.getByText("Аплинк каталога",{exact:true})).toBeVisible();await expect(page.getByText("Модель связей с JH · Ping модели",{exact:true})).toBeVisible()
await expect(page.getByText("primary",{exact:true})).toHaveCount(0);await expect(page.getByText("backup",{exact:true})).toHaveCount(0)
})
@@ -31,7 +31,7 @@ test("Карта: выбранный GRE обновляется и неизве
await expect(page.getByText("gre-home",{exact:true}).last()).toBeVisible();await expect(page.getByText("Ping неизвестен",{exact:true})).toBeVisible()
changed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByText("gre-new",{exact:true}).last()).toBeVisible();await expect(page.getByText("Недоступен",{exact:true})).toBeVisible()
await page.getByText("Домашний",{exact:true}).first().click();await expect(page.getByText("IPsec неизвестен",{exact:true})).toBeVisible()
await page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Домашний",{exact:true}).click();await expect(page.getByText("IPsec неизвестен",{exact:true})).toBeVisible()
})
test("Карта: GRE сохраняет частичное измерение, ноль Ping и дату пробы",async({page})=>{
+1 -1
View File
@@ -24,7 +24,7 @@ test("Карта: NetFlow сохраняет сервис при фоновом
for(const result of ["missing","foreign","unresolved","success"])test("Карта: DNS "+result,async({page})=>{
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p===flowPath?empty:p==="/api/servers"?[{...server,wanUplinks:[{id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"router.example",maxDl:100,maxUl:100}]}]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/network/resolve-hosts"?(result==="missing"?{}:{results:result==="foreign"?{"foreign.example":"192.0.2.2"}:{"router.example":result==="unresolved"?null:"192.0.2.2"}}):p==="/api/auth/config"?{required:false}:{}})})
await page.goto("/network-map")
await expect(page.getByText("Router",{exact:true}).first()).toBeVisible()
await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Router",{exact:true})).toBeVisible()
if(result==="success")await expect(page.getByRole("alert").filter({hasText:"DNS:"})).toHaveCount(0)
else await expect(page.getByRole("alert").filter({hasText:"Связи по неизвестному адресу"})).toBeVisible()
})
+46
View File
@@ -0,0 +1,46 @@
import {test,expect} from "@playwright/test"
const empty={"hops":[],"live":false,"rangeMinutes":5,"windowSec":300,"totalBytes":0,"namedBytes":0,"unclassifiedBytes":0,"asnLoaded":false,"countryLoaded":false,"services":[],"serviceEdges":[],"servicePaths":[],"countries":[],"countryEdges":[],"countryPaths":[],"countryServiceGroups":[],"mapServiceMinSharePct":5,"dedupApplied":false,"excludeMeshApplied":false,"excludeOverlayApplied":false}
const wan={id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"192.0.2.1",maxDl:100,maxUl:100}
const home={id:1,name:"Домашний",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:2,wanUplinks:[wan]}
const jh={id:2,name:"Jump",host:"192.0.2.2",type:"jump-host",country:"RU",enabled:true,status:"online",latency:3}
const tunnel={id:"t1",serverId:"1",name:"gre-home",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1",remoteInnerIp:"10.0.0.2",poolId:"",ipsec:null,mtu:1476,keepaliveInterval:10,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
test.beforeEach(async({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"?[{...home,name:"Домашний роутер с очень длинным названием и описанием устройства"},{...jh,name:"Промежуточный узел с длинной подписью"}]:p==="/api/filters/gre-tunnels"?{tunnels:[tunnel],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
})
for(const width of [1440,390,320])test("Карта: доступные детали и адаптивная оболочка "+width,async({page})=>{
await page.setViewportSize({width,height:900});await page.goto("/network-map")
await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/network-map-overview-"+(width===1440?"desktop":width===390?"mobile":"narrow")+".png"})
await page.getByRole("combobox",{name:"Устройство на карте"}).selectOption("1")
const panel=page.getByRole("complementary",{name:"Детали объекта карты"})
await expect(panel).toBeVisible();await expect(panel.getByText("Ресурсы",{exact:true})).toBeVisible()
const bounds=await panel.boundingBox(),map=width>=1024?await page.getByRole("group",{name:"Интерактивная карта сети"}).boundingBox():null
expect(bounds!.width).toBeGreaterThan(250);expect(bounds!.height).toBeGreaterThan(350)
if(width<1024)await expect(page.getByRole("group",{name:"Интерактивная карта сети"})).toBeHidden()
else expect(map!.height).toBeGreaterThan(0)
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/network-map-detail-"+(width===1440?"desktop":width===390?"mobile":"narrow")+".png"})
await panel.getByRole("button",{name:"Закрыть детали устройства"}).click()
await expect(panel).toHaveCount(0)
const node=page.getByRole("button",{name:/^Устройство Домашний/});await node.focus();await node.press("Enter");await expect(panel).toBeVisible()
await page.getByRole("button",{name:"Действия устройства",exact:true}).click()
const dialog=page.getByRole("dialog",{name:/^Действия устройства/});await expect(dialog).toBeVisible()
await expect(dialog.getByRole("link",{name:"GRE-туннели",exact:true})).toHaveAttribute("href","/gre?server=1")
await page.keyboard.press("Tab");expect(await dialog.evaluate(element=>element.contains(document.activeElement))).toBe(true)
await page.keyboard.press("Escape");await expect(dialog).toHaveCount(0);await expect(page.getByRole("button",{name:"Действия устройства",exact:true})).toBeFocused()
await expect(page).toHaveURL(/server=1/)
await page.getByRole("button",{name:"Слои",exact:true}).click();await expect(page.getByRole("dialog",{name:"Слои карты"})).toBeVisible()
await page.keyboard.press("Escape");await expect(page.getByRole("button",{name:"Слои",exact:true})).toBeFocused();await expect(page).toHaveURL(/server=1/)
})
test("Карта: reduced motion и самостоятельный SVG экспорт",async({page})=>{
await page.emulateMedia({reducedMotion:"reduce"});await page.goto("/network-map")
await expect(page.getByRole("button",{name:/^Устройство Домашний/})).toBeVisible()
await expect(page.locator("animateMotion")).toHaveCount(0)
const wait=page.waitForEvent("download");await page.getByRole("button",{name:"Экспорт SVG",exact:true}).click();const download=await wait
const stream=await download.createReadStream();let source="";for await(const chunk of stream!)source+=chunk.toString()
expect(source).toContain("--foreground:");expect(source).toContain("--background:");expect(source).toContain('xmlns="http://www.w3.org/2000/svg"')
const parsed=await page.evaluate(source=>{const doc=new DOMParser().parseFromString(source,"image/svg+xml");return {errors:doc.querySelectorAll("parsererror").length,width:doc.documentElement.getAttribute("width")}},source)
expect(parsed.errors).toBe(0);expect(parsed.width).toBe("1240")
})