diff --git a/components/server-rail-layout.tsx b/components/server-rail-layout.tsx index 2be912d4..97e46d03 100644 --- a/components/server-rail-layout.tsx +++ b/components/server-rail-layout.tsx @@ -67,9 +67,10 @@ function ServerRailMobileButton() { const { openRail, expandRail, collapsed, selectedLabel } = useServerRail() return ( ) } @@ -123,8 +124,9 @@ function ServerRailLayout({ const [hydrated, setHydrated] = useState(false) useEffect(() => { - setCollapsed(readCollapsed()) - setHydrated(true) + let cancelled=false + queueMicrotask(()=>{if(cancelled)return;setCollapsed(readCollapsed());setHydrated(true)}) + return()=>{cancelled=true} }, []) useEffect(() => { diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index ddf5b8fd..e15c65a9 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -190,3 +190,12 @@ HTTP-фикстуры; локальная приёмка: 43 основных + | Детали · 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.53 (16 E2E + 2 unit). Pointer/touch и пустые состояния приняты в 10.54: 39 основных + 12 source E2E, 10 unit и production frontend build. Реальные RouterOS/JWT/БД остаются внешней приёмкой; далее терминал. + +### Терминал: локальная приёмка 10.57 + +REST запрос → ожидание → подтверждённый output / ошибка с сохранением команды. Выбор устройства и поле команды остаются доступными на ПК и телефоне. Референс — существующий ReUI Frame и List 9; детали проверок в журнале. + +| Экран | ПК | Телефон | +| --- | --- | --- | +| Готовность | [PNG](ux-ui/screens/terminal-ready-desktop.png) | [390 px](ux-ui/screens/terminal-ready-mobile.png), [320 px](ux-ui/screens/terminal-ready-narrow.png) | +| Вывод | [PNG](ux-ui/screens/terminal-output-desktop.png) | [390 px](ux-ui/screens/terminal-output-mobile.png), [320 px](ux-ui/screens/terminal-output-narrow.png) | diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 33af2189..b710c413 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,7 +26,7 @@ | 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.56; оптимизатор и карта приняты локально, терминал в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.57; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 10.57 — локальная приёмка терминала (2026-10-01) + +- Терминал принят локально: 20 основных E2E (17 данных и 3 адаптивных), 8 source E2E, TypeScript, lint и production frontend build прошли. Проверены timeout/поздний ответ, пустой/disabled каталог, неверный URL, сохранение команды, смена режима/backend и 1200 строк вывода. +- Снимки ready/output для ПК, 390 и 320 px сохранены. Общая мобильная кнопка ServerRail теперь ограничена шириной и сокращает длинное имя; гидратация учитывает cleanup. 12 регрессионных сценариев GRE/VXLAN/IPsec/OSPF/recursive прошли; восемь обновлённых снимков просмотрены и сохранены. ReUI Frame checklist повторён. +- Границы: HTTP fixtures/DI доказывают обработку UI/API, не реальный RouterOS/БД/портал. REST терминал не является SSH-сессией; clear локальный, отмена/timeout не подтверждают остановку на устройстве. +- Этап 10 остаётся в работе: обнаружен отдельный /probes (Диагностика GRE), который ещё требует приёмки. Далее 10.58: строгий результат, single-flight и отмена запросов диагностики; каталоги/URL/права и backend — отдельными блоками. Расписание относится к 11Б. + ## Блок 10.56 — честный backend exec (2026-10-01) - BFD/API отказ не подменяется пустым успехом: upstream ошибка возвращает 502/error, неподдерживаемая команда или неверные аргументы — 400/error; успешный output сохраняет прежнюю форму. Frontend 10.55 понимает новые ошибки и legacy error: output. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 144c91db..73f555f8 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -30,8 +30,9 @@ | 3 | Рекурсивные маршруты | Ответы каталогов и смена устройства требуют проверки; операции связаны с историей | Readiness, точный serverId, повтор/ошибка, URL | | 4 | Оптимизатор | Ошибка зависимого каталога скрывается; общий сбой удаляет последние данные | Неполнота явно показана, применение требует достоверного контекста | | 5 | Карта сети | GRE failures игнорируются; отказ GRE/probes превращается в пустые данные; нет общей защиты поздних ответов | Кэш и неизвестные рёбра/узлы, смена источника, пустые/ошибочные состояния | -| 6 | Терминал и диагностика | Проверить права, готовность устройства, поздний вывод и повторные отправки | Точный контекст, сохранение вывода, подтверждение выполнения | -| 7 | Приёмка | ПК/телефон, клавиатура, переходы и профильная регрессия | Схемы/PNG, сборки, сценарии; внешние ограничения отдельно | +| 6 | Терминал | Проверить права, готовность устройства, поздний вывод и повторные отправки | Точный контекст, сохранение вывода, подтверждение выполнения | +| 7 | Диагностика GRE: /servers/:id/probes/run, ros-src-address, filters/gre-tunnels | Отмена показана как done, нет строгого output/single-flight/source cleanup; каталоги маскируют ошибки | Подтверждённый результат, отмена отдельно, точный контекст, ошибки и права; расписание в 11Б | +| 8 | Приёмка | ПК/телефон, клавиатура, переходы и профильная регрессия | Схемы/PNG, сборки, сценарии; внешние ограничения отдельно | ## Совместимость и границы @@ -329,3 +330,11 @@ Shared контракт и backend observed исходные значения/д - Отсутствующие BGP/BFD status, OSPF cost/type/BFD/version/area/changes и firewall counter не выдаются за active/down/10/broadcast/no/2/0. Ping timeout виден без TTL, неизвестная packet loss — ?, нулевые измерения сохранены. Resource memory отказывается от NaN/overflow. - createExecRoutes допускает DI для тестов; production defaults — прежние DB/MikrotikClient. test:exec включён в backend test. Route/RBAC DI тесты, backend test:auth, build и lint прошли. Подписанные JWT проверяют текущий middleware до device lookup; это не приёмка реального портала/RouterOS/БД. - Далее source/cancel/timeout/пустой/disabled/invalid URL E2E терминала, адаптивные снимки и production frontend; затем локальное завершение этапа 10. + +## Блок 10.57 — локальная приёмка терминала (2026-10-01) + +- Терминал принят локально: 20 основных E2E (17 данных и 3 адаптивных), 8 source E2E, TypeScript, lint и production frontend build прошли. Проверены timeout/поздний ответ, пустой/disabled каталог, неверный URL, сохранение команды, смена режима/backend и 1200 строк вывода. +- Снимки ready/output для ПК, 390 и 320 px сохранены. Общая мобильная кнопка ServerRail теперь ограничена шириной и сокращает длинное имя; гидратация учитывает cleanup. 12 регрессионных сценариев GRE/VXLAN/IPsec/OSPF/recursive прошли; восемь обновлённых снимков просмотрены и сохранены. ReUI Frame checklist повторён. +- Границы: HTTP fixtures/DI доказывают обработку UI/API, не реальный RouterOS/БД/портал. REST терминал не является SSH-сессией; clear локальный, отмена/timeout не подтверждают остановку на устройстве. +- Этап 10 остаётся в работе: обнаружен отдельный /probes (Диагностика GRE), который ещё требует приёмки. Далее 10.58: строгий результат, single-flight и отмена запросов диагностики; каталоги/URL/права и backend — отдельными блоками. Расписание относится к 11Б. + diff --git a/docs/ux-ui/screens/gre-mobile.png b/docs/ux-ui/screens/gre-mobile.png index 29d7fb28..aea90184 100644 Binary files a/docs/ux-ui/screens/gre-mobile.png and b/docs/ux-ui/screens/gre-mobile.png differ diff --git a/docs/ux-ui/screens/ipsec-servers-mobile.png b/docs/ux-ui/screens/ipsec-servers-mobile.png index 5972d82f..dde5442f 100644 Binary files a/docs/ux-ui/screens/ipsec-servers-mobile.png and b/docs/ux-ui/screens/ipsec-servers-mobile.png differ diff --git a/docs/ux-ui/screens/ospf-neighbors-desktop.png b/docs/ux-ui/screens/ospf-neighbors-desktop.png index f8d2299f..fec39e52 100644 Binary files a/docs/ux-ui/screens/ospf-neighbors-desktop.png and b/docs/ux-ui/screens/ospf-neighbors-desktop.png differ diff --git a/docs/ux-ui/screens/recursive-routes-detail-desktop.png b/docs/ux-ui/screens/recursive-routes-detail-desktop.png index 165df00b..51d8d5d6 100644 Binary files a/docs/ux-ui/screens/recursive-routes-detail-desktop.png and b/docs/ux-ui/screens/recursive-routes-detail-desktop.png differ diff --git a/docs/ux-ui/screens/recursive-routes-detail-mobile.png b/docs/ux-ui/screens/recursive-routes-detail-mobile.png index a1cbf258..a253753c 100644 Binary files a/docs/ux-ui/screens/recursive-routes-detail-mobile.png and b/docs/ux-ui/screens/recursive-routes-detail-mobile.png differ diff --git a/docs/ux-ui/screens/recursive-routes-detail-narrow.png b/docs/ux-ui/screens/recursive-routes-detail-narrow.png index 32bafc33..0683cb6a 100644 Binary files a/docs/ux-ui/screens/recursive-routes-detail-narrow.png and b/docs/ux-ui/screens/recursive-routes-detail-narrow.png differ diff --git a/docs/ux-ui/screens/recursive-routes-form-desktop.png b/docs/ux-ui/screens/recursive-routes-form-desktop.png index 27c9ffb2..5ed0ded3 100644 Binary files a/docs/ux-ui/screens/recursive-routes-form-desktop.png and b/docs/ux-ui/screens/recursive-routes-form-desktop.png differ diff --git a/docs/ux-ui/screens/terminal-output-desktop.png b/docs/ux-ui/screens/terminal-output-desktop.png new file mode 100644 index 00000000..32503df7 Binary files /dev/null and b/docs/ux-ui/screens/terminal-output-desktop.png differ diff --git a/docs/ux-ui/screens/terminal-output-mobile.png b/docs/ux-ui/screens/terminal-output-mobile.png new file mode 100644 index 00000000..5f6bf237 Binary files /dev/null and b/docs/ux-ui/screens/terminal-output-mobile.png differ diff --git a/docs/ux-ui/screens/terminal-output-narrow.png b/docs/ux-ui/screens/terminal-output-narrow.png new file mode 100644 index 00000000..18d43838 Binary files /dev/null and b/docs/ux-ui/screens/terminal-output-narrow.png differ diff --git a/docs/ux-ui/screens/terminal-ready-desktop.png b/docs/ux-ui/screens/terminal-ready-desktop.png new file mode 100644 index 00000000..0c0324bb Binary files /dev/null and b/docs/ux-ui/screens/terminal-ready-desktop.png differ diff --git a/docs/ux-ui/screens/terminal-ready-mobile.png b/docs/ux-ui/screens/terminal-ready-mobile.png new file mode 100644 index 00000000..eb64417b Binary files /dev/null and b/docs/ux-ui/screens/terminal-ready-mobile.png differ diff --git a/docs/ux-ui/screens/terminal-ready-narrow.png b/docs/ux-ui/screens/terminal-ready-narrow.png new file mode 100644 index 00000000..478d083c Binary files /dev/null and b/docs/ux-ui/screens/terminal-ready-narrow.png differ diff --git a/docs/ux-ui/screens/vxlan-mobile.png b/docs/ux-ui/screens/vxlan-mobile.png index 01ef21c5..c7bca5c1 100644 Binary files a/docs/ux-ui/screens/vxlan-mobile.png and b/docs/ux-ui/screens/vxlan-mobile.png differ diff --git a/playwright.config.ts b/playwright.config.ts index 1a29a882..ce1f47f3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/playwright.terminal-context.config.ts b/playwright.terminal-context.config.ts new file mode 100644 index 00000000..91f52d2c --- /dev/null +++ b/playwright.terminal-context.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" +const server=base.webServer +if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер") +export default defineConfig({ + ...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"terminal-context.spec.ts", + webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}}, +}) diff --git a/tests/e2e/terminal-context.spec.ts b/tests/e2e/terminal-context.spec.ts new file mode 100644 index 00000000..8dbd7cae --- /dev/null +++ b/tests/e2e/terminal-context.spec.ts @@ -0,0 +1,22 @@ +import {test,expect} from "@playwright/test" +const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} +const row=(name:string)=>({id:1,name,host:"192.0.2.1",country:"RU",enabled:true,status:"online",os:null}) +for(const change of ["backend","mode"])for(const part of ["catalog","exec"])for(const fails of [false,true])test("Терминал: поздний "+part+"/"+change+"/"+fails,async({page})=>{ + let started=false,completed=false,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + 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;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[row(u.port==="8101"?"Новый источник":"Прежний источник")]:p.endsWith("/exec")?{output:"новый вывод"}:{}})}) + await page.goto("/settings");await page.getByRole("link",{name:/^Терминал/}).click();await page.getByRole("option",{name:/Прежний источник/}).click() + await expect(page.getByRole("textbox",{name:"Команда · Прежний источник"})).toBeEnabled() + const path=part==="catalog"?"/api/servers":"/api/servers/1/exec" + await page.route("**/api/**",async r=>{const u=new URL(r.request().url());if(u.port!=="8000"||u.pathname!==path||started)return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"Прежняя ошибка терминала"}}:{headers,json:part==="catalog"?[row("Поздний каталог")]:{output:"Поздний вывод терминала"}})}catch{}finally{completed=true}}) + if(part==="catalog")await page.getByRole("button",{name:"Обновить устройства",exact:true}).click() + else{await page.getByRole("textbox",{name:/^Команда ·/}).fill("interface print");await page.getByRole("button",{name:"Выполнить",exact:true}).click()} + await expect.poll(()=>started).toBe(true);await page.getByRole("link",{name:/^Настройки/}).click() + 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 page.getByRole("option",{name:/Новый источник/}).click();else await page.getByRole("option").first().click() + await expect(page.getByRole("textbox",{name:/^Команда ·/})).toBeEnabled() + release();await expect.poll(()=>completed).toBe(true);await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))) + await expect(page.getByText("Поздний каталог",{exact:false})).toHaveCount(0);await expect(page.getByText("Поздний вывод терминала",{exact:false})).toHaveCount(0);await expect(page.getByText("Прежняя ошибка терминала",{exact:false})).toHaveCount(0) + if(change==="backend")await expect(page.getByRole("textbox",{name:"Команда · Новый источник"})).toBeEnabled();else await expect(page.getByRole("status").filter({hasText:"Демо: команды не отправляются"})).toBeVisible() +}) diff --git a/tests/e2e/terminal-data.spec.ts b/tests/e2e/terminal-data.spec.ts index 1303c087..65cb0c81 100644 --- a/tests/e2e/terminal-data.spec.ts +++ b/tests/e2e/terminal-data.spec.ts @@ -46,3 +46,21 @@ test("Терминал: reader видит устройство, выполнен await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}})) await page.goto("/terminal?server=1");await expect(command(page)).toBeDisabled();await expect(page.getByRole("status").filter({hasText:"Для выполнения требуется mm:servers:write"})).toBeVisible() }) + +for(const q of ["server=1&server=2","server=1&host=192.0.2.1","server=bad%20id"])test("Терминал: неверный URL не читает каталог "+q,async({page})=>{ + let reads=0;await page.route("**/api/servers",r=>{reads++;return r.fulfill({json:devices})}) + await page.goto("/terminal?"+q);await expect(page.getByRole("alert").filter({hasText:"Некорректная ссылка"})).toBeVisible();expect(reads).toBe(0) + await page.getByRole("button",{name:"Сбросить контекст терминала"}).click();await expect(page.getByRole("option",{name:/Первый/})).toBeVisible();expect(reads).toBe(1) +}) +test("Терминал: disabled и известный пустой каталог",async({page})=>{ + await page.route("**/api/servers",r=>r.fulfill({json:[{...devices[0],enabled:false}]}));await page.goto("/terminal?server=1") + await expect(command(page)).toBeDisabled();await expect(page.getByRole("status").filter({hasText:"Устройство отключено"})).toBeVisible() + await page.route("**/api/servers",r=>r.fulfill({json:[]}));await page.goto("/terminal");await expect(page.getByText("Устройств пока нет",{exact:true})).toBeVisible();await expect(command(page)).toHaveCount(0) +}) +test("Терминал: таймаут сохраняет команду и не повторяет автоматически",async({page})=>{ + let calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + await page.route("**/api/servers/1/exec",async r=>{calls++;await gate;await r.fulfill({json:{output:"запоздалый ответ"}}).catch(()=>{})}) + await page.clock.install();await page.goto("/terminal?server=1");await command(page).fill("interface print");await command(page).press("Enter");await expect.poll(()=>calls).toBe(1) + await page.clock.fastForward(60010);await expect(page.getByRole("alert").filter({hasText:"Время ожидания истекло"})).toBeVisible();await expect(command(page)).toHaveValue("interface print");await expect(command(page)).toBeEnabled();expect(calls).toBe(1) + release();await expect(page.getByRole("region",{name:"Вывод терминала"})).not.toContainText("запоздалый ответ") +}) diff --git a/tests/e2e/terminal-layout.spec.ts b/tests/e2e/terminal-layout.spec.ts new file mode 100644 index 00000000..56f91cf7 --- /dev/null +++ b/tests/e2e/terminal-layout.spec.ts @@ -0,0 +1,18 @@ +import {test,expect} from "@playwright/test" +for(const width of [1440,390,320])test("Терминал: адаптивный вывод, поле и быстрые команды "+width,async({page})=>{ + const row={id:1,name:"Домашний роутер с длинным названием и описанием устройства",host:"192.0.2.1",country:"RU",enabled:true,status:null,os:null} + 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"?[row]:p==="/api/auth/config"?{required:false}:p.endsWith("/exec")?{output:Array.from({length:1200},(_,i)=>"Строка "+i+" "+"x".repeat(160)).join("\n")}:{}})}) + await page.setViewportSize({width,height:900});await page.goto("/terminal?server=1") + const input=page.getByRole("textbox",{name:/^Команда ·/}),output=page.getByRole("region",{name:"Вывод терминала"}) + await expect(input).toBeEnabled();await expect(output).not.toContainText("Connected") + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) + if(width<768){const control=(await page.getByRole("button",{name:"Серверы",exact:true}).boundingBox())!;expect(control.x+control.width).toBeLessThanOrEqual(width)} + await page.screenshot({path:"docs/ux-ui/screens/terminal-ready-"+(width===1440?"desktop":width===390?"mobile":"narrow")+".png"}) + await page.getByRole("combobox",{name:"Быстрая команда"}).selectOption("interface print");await input.press("Enter") + await expect(input).toHaveValue("");await expect(output).toContainText("Показаны последние 1000 строк");await expect(output).toContainText("Строка 1199");await expect(output).not.toContainText("Строка 0 ") + expect(await output.evaluate(e=>e.scrollHeight>e.clientHeight)).toBe(true);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) + const box=(await input.boundingBox())!;expect(box.width).toBeGreaterThan(100);expect(box.y+box.height).toBeLessThanOrEqual(900) + await page.screenshot({path:"docs/ux-ui/screens/terminal-output-"+(width===1440?"desktop":width===390?"mobile":"narrow")+".png"}) + await page.getByRole("button",{name:"Очистить вывод терминала"}).click();await expect(output).toContainText("Вывод очищен.") + if(width<768){await page.getByRole("button",{name:"Серверы",exact:true}).click();const dialog=page.getByRole("dialog");await expect(dialog).toBeVisible();await page.keyboard.press("Escape");await expect(dialog).toHaveCount(0);await expect(page.getByRole("button",{name:"Серверы",exact:true})).toBeFocused()} +})