diff --git a/components/data-grids/route-optimizer-full-routes-data-grid.tsx b/components/data-grids/route-optimizer-full-routes-data-grid.tsx index d306c9ca..e9ab8606 100644 --- a/components/data-grids/route-optimizer-full-routes-data-grid.tsx +++ b/components/data-grids/route-optimizer-full-routes-data-grid.tsx @@ -1,8 +1,8 @@ "use client" -import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" +import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { type ColumnDef, useTable, @@ -76,13 +76,14 @@ function RouteOptimizerFullRoutesDataGrid({ () => [ { id: "index", + size: 112, header: () => # Маршрут, enableSorting: false, cell: ({ row }) => { const isBest = row.original.id === bestId || row.original._index === 0 return (
- + {row.original._index + 1} {isBest && ( @@ -97,6 +98,7 @@ function RouteOptimizerFullRoutesDataGrid({ }, { id: "wanJh", + size: 264, header: () => WAN → JH, enableSorting: false, cell: ({ row }) => { @@ -118,6 +120,7 @@ function RouteOptimizerFullRoutesDataGrid({ }, { id: "jhExit", + size: 240, header: () => JH → Exit, enableSorting: false, cell: ({ row }) => { @@ -139,6 +142,7 @@ function RouteOptimizerFullRoutesDataGrid({ }, { id: "totalPing", + size: 144, accessorFn: (row) => row.hw.pingMs + row.je.pingMs, header: ({ column }) => ( @@ -168,6 +172,7 @@ function RouteOptimizerFullRoutesDataGrid({ }, { id: "bw", + size: 144, header: () => ( BW (мин) @@ -192,6 +197,7 @@ function RouteOptimizerFullRoutesDataGrid({ }, { id: "score", + size: 80, accessorKey: "score", header: ({ column }) => ( @@ -209,6 +215,7 @@ function RouteOptimizerFullRoutesDataGrid({ }, { id: "prob", + size: 104, accessorKey: "probabilityOptimal", header: ({ column }) => ( @@ -229,6 +236,7 @@ function RouteOptimizerFullRoutesDataGrid({ }, { id: "confidence", + size: 176, accessorKey: "confidence", header: ({ column }) => ( @@ -250,28 +258,41 @@ function RouteOptimizerFullRoutesDataGrid({ const table = useTable({ features: dataGridFeatures, - manualPagination: true, + manualPagination: false, + autoResetPageIndex: false, + initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index"], end: [] } }, data: indexed, columns, getRowId: (row) => row.id, }) + const { pageIndex, pageSize } = table.state.pagination + useEffect(() => { + const lastPage = Math.max(0, Math.ceil(indexed.length / pageSize) - 1) + if (pageIndex > lastPage) table.setPageIndex(lastPage) + }, [indexed.length, pageIndex, pageSize, table]) + return ( -
+
50 || pageSize !== 50)} tableClassNames={{ headerRow: "border-b bg-muted/20", bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-success/5"), }} - tableLayout={{ dense: true }} - /> + tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }} + > + + + + {routes.length > 5 && ( )} -
+ ) } diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index 6b96f2dc..52779c10 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -15,7 +15,7 @@ ## Оставшиеся специализированные grids -Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —8 файлов после12.3.53. Это не8 доказанных дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API. +Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —7 файлов после12.3.57. Это не7 доказанных дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API. | Файл | Источник и необходимая проверка | | --- | --- | @@ -23,7 +23,6 @@ | [gre-pools-data-grid.tsx](../components/data-grids/gre-pools-data-grid.tsx) | В live displayPools=[]; только mockGrePools в demo. Приоритета live1000 нет. | | [probes-schedule-data-grid.tsx](../components/data-grids/probes-schedule-data-grid.tsx) | Только demo ScheduleTab/INIT_RULES3; live использует ProbesSpeedSchedule. Demo список расширяется пользователем; не доказательство live API. | | [route-optimizer-comm-recs-data-grid.tsx](../components/data-grids/route-optimizer-comm-recs-data-grid.tsx) | Live commRecs slice(0,24) на HomeCard, не на страницу. Проверить количество HomeCard и честность ограничения рекомендаций. | -| [route-optimizer-full-routes-data-grid.tsx](../components/data-grids/route-optimizer-full-routes-data-grid.tsx) | Live сочетания WAN×jumpHost×exitNode на HomeCard; ограничение DOM не устраняет стоимость расчёта полного массива. | | [route-optimizer-wan-matrix-data-grid.tsx](../components/data-grids/route-optimizer-wan-matrix-data-grid.tsx) | Live сочетания WAN×jumpHost на HomeCard; динамические столбцы/много HomeCard требуют собственного профиля. | | [subusers-data-grid.tsx](../components/data-grids/subusers-data-grid.tsx) | Поиск импортов/JSX в app/components не нашёл потребителя: компонент экспортируется, но не подключён. Не доказывает дефект экрана /users. | | [traffic-flows-data-grid.tsx](../components/data-grids/traffic-flows-data-grid.tsx) | FlowAnalyticsPanel/conversationsList: backend traffic-flow-analytics.ts top=min(50,max(10,settings.topN)), slice(0,top). Это top slice, не полный каталог/серверная pagination. Проверить сообщение об ограничении50 и связывание с графиками. | @@ -107,3 +106,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич 12.3.55:1000 HomeRouterCard →5/1870 page DOM/1606ms dev ready, full search/URL/refresh/cache/clamp/320px/axe/pin preserved.23+8 main/8 context/type/lint/build. Полный CPU расчёт и1000 native device options остаются; далее1000 full routes. 12.3.56:WAN10×JH10×Exit10/21 servers/4261 bytes →1000 expanded full-route rows/33049 table DOM/33780 page DOM/3738ms dev ready.1 browser/test lint; baseline, далее12.3.57. + +12.3.57:1000 full routes→50 rows/1699 table nodes/2461 page DOM/1853ms dev ready.6+1 browser/type/lint/build, global sort/index/page20/refresh/clamp/320px/axe.7 manualPagination:true, далее WAN matrix/CPU/24 commRecs. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 03a9e8dc..6a96b184 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-04: 12.3.56 baseline1000 full routes сохранён:33049 table DOM/33780 page DOM/3738ms dev ready;1 browser/test lint. Далее12.3.57 — client pagination раскрытого списка/полная сортировка/refresh/clamp/320px; затем WAN matrix/общая регрессия. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.57 принят локально:1000 full routes→50/1699 table DOM/2461 page DOM, global sort/индекс/page20/refresh/clamp/320px/axe.6+1 browser/type/lint/build. Далее12.3.58 — профиль WAN matrix/динамических столбцов и CPU комбинирования; честность24 рекомендаций/полная регрессия после delta.7 specialized manualPagination:true остаются. Внешняя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 1cd5edbd..be5f3f09 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-04: 12.3.56 baseline1000 full routes сохранён:33049 table DOM/33780 page DOM/3738ms dev ready;1 browser/test lint. Далее12.3.57 — client pagination раскрытого списка/полная сортировка/refresh/clamp/320px; затем WAN matrix/общая регрессия. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.57 принят локально:1000 full routes→50/1699 table DOM/2461 page DOM, global sort/индекс/page20/refresh/clamp/320px/axe.6+1 browser/type/lint/build. Далее12.3.58 — профиль WAN matrix/динамических столбцов и CPU комбинирования; честность24 рекомендаций/полная регрессия после delta.7 specialized manualPagination:true остаются. Внешняя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 557ec250..6c33dd29 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -651,3 +651,12 @@ OptimizerHomesList показывает5 карточек на страницу, ## 12.3.56 — исходный профиль1000 полных маршрутов (2026-10-04) Полный валидный servers DTO21 устройство/4261 bytes:WAN10×JH10×Exit10 создаёт1000 full routes.1 browser/test lint passed; раскрытый список1000 rows/33049 table DOM/33780 page DOM/3738ms dev ready. [Профиль](ux-ui/acceptance/optimizer-routes-baseline-2026-10-04.json) сохранён. Это baseline, не performance acceptance; dev latency не production benchmark. Продуктовый код не менялся, build повторно не нужен. Далее12.3.57 ограничить DOM раскрытого списка50/page без потери global sort/индекса/best/refresh/clamp; затем WAN matrix/CPU combinatorics/24 commRecs и общая регрессия. Внешняя12.5 отдельно. Локальный коммит без push. + + +## 12.3.57 — страницы полных маршрутов оптимизатора (2026-10-04) + +ReUI client pagination50 только раскрытого списка, autoResetPageIndex=false/clamp, fixed widths/ScrollArea, закреплённый исходный номер маршрута112px. _index/bestId вычислены по полному исходному списку, не странице/sort. Сворачивание/раскрытие явно reset page0; для1000 текст «Открыть все1000 комбинаций» не обещает одновременный DOM. Для<=50 прежний текст сохранён. Toggle44px. Расчёт модели/сети и полный массив не изменены. + +6/6 профильных table/pin/empty/unknown cases и финальный large1/1 passed. Первые ошибочные expectations теста считали min index уникальным; исправлены по существующим равным значениям. Проверены50 initial/счётчик номера51/refresh/page20 951–1000, collapse5/open50, глобальный min sort включает номера за исходной page1 при ties,320px/pinning/axe/shrink1000→1/no writes.21 devices/WAN10×JH10×Exit10/4261 bytes →50 rows/1699 table DOM/2461 page DOM/1853ms dev ready вместо33049/33780. TypeScript без incremental/ESLint grid+test/production build passed. [Профиль](ux-ui/acceptance/optimizer-routes-pages-2026-10-04.json), [320px](ux-ui/screens/optimizer-routes-pages-320.png) сохранены и просмотрены. Dev latency не production benchmark. + +Осталось7 manualPagination:true specialized grids. Следующий12.3.58 — WAN matrix/динамические JH columns/CPU combinatorics. Срез commRecs24/восстановление вкладки карточки/native device options1000 и полный массив в памяти остаются. Общая main/context после всех delta и внешняя12.5 не закрыты. Локальный коммит без push. diff --git a/docs/ux-ui/acceptance/optimizer-routes-pages-2026-10-04.json b/docs/ux-ui/acceptance/optimizer-routes-pages-2026-10-04.json new file mode 100644 index 00000000..b8ec4122 --- /dev/null +++ b/docs/ux-ui/acceptance/optimizer-routes-pages-2026-10-04.json @@ -0,0 +1,9 @@ +{ + "combinations": 1000, + "servers": 21, + "bytes": 4261, + "rendered": 50, + "tableNodes": 1699, + "pageNodes": 2461, + "readyMs": 1853 +} \ No newline at end of file diff --git a/docs/ux-ui/screens/optimizer-routes-pages-320.png b/docs/ux-ui/screens/optimizer-routes-pages-320.png new file mode 100644 index 00000000..0430c90c Binary files /dev/null and b/docs/ux-ui/screens/optimizer-routes-pages-320.png differ diff --git a/tests/e2e/large-optimizer-routes.spec.ts b/tests/e2e/large-optimizer-routes.spec.ts index 3c296490..90683175 100644 --- a/tests/e2e/large-optimizer-routes.spec.ts +++ b/tests/e2e/large-optimizer-routes.spec.ts @@ -1,26 +1,63 @@ import { test, expect } from "@playwright/test" import { writeFileSync } from "node:fs" +import axe from "axe-core" import { optimizerServersSchema } from "../../lib/route-optimizer-response" test("Оптимизатор1000 маршрутов: WAN10 × JH10 × Exit10", async ({ page }, info) => { test.setTimeout(90000) const common = { host: "192.0.2.1", enabled: true, status: "online", latency: 10, country: "RU", site: "TEST", model: "CHR" } - const servers = optimizerServersSchema.parse([ + let servers = optimizerServersSchema.parse([ { ...common, id: 1, name: "Домашний роутер", type: "home-router", wanUplinks: Array.from({ length: 10 }, (_, i) => ({ id: `wan${i+1}`, name: `WAN${i+1}`, isp: "ISP", iface: `ether${i+1}`, ip: `198.51.100.${i+1}`, maxDl: 100, maxUl: 100 })) }, ...Array.from({ length: 10 }, (_, i) => ({ ...common, id: i+2, name: `Jump ${i+1}`, type: "jump-host" })), ...Array.from({ length: 10 }, (_, i) => ({ ...common, id: i+12, name: `Exit ${i+1}`, type: "exit-node" })), ]) + let reads = 0 + 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 (path === "/api/servers") reads++ + if (r.request().method() !== "GET" && !path.endsWith("/preview")) writes.push(path) return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? servers : path === "/api/filters/rules" ? { rulesets: [] } : path === "/api/uptime/speed-probes" ? { probes: [] } : path === "/api/ospf/catalog" ? { interfaces: [], instances: [], neighbors: [], bfdSessions: [], routes: [], failures: [] } : path.endsWith("/ospf/optimize/preview") ? { serverId: 1, serverName: "Домашний роутер", pingWeight: 60, interfacesTotal: 0, changedCount: 0, unchangedCount: 0, interfaces: [], changes: [] } : {} }) }) const start = Date.now(); await page.goto("/route-optimizer?server=1&keep=1") await page.getByRole("button", { name: "Маршруты (1000)", exact: true }).click() const table = page.getByRole("table").filter({ hasText: "# Маршрут" }) await expect(table.locator("tr[data-row-id]")).toHaveCount(5) - await page.getByRole("button", { name: "Показать все 1000 комбинаций", exact: true }).click() - await expect(table.locator("tr[data-row-id]")).toHaveCount(1000) + await page.getByRole("button", { name: "Открыть все 1000 комбинаций", exact: true }).click() + await expect(table.locator("tr[data-row-id]")).toHaveCount(50) const profile = { combinations: 1000, servers: servers.length, bytes: Buffer.byteLength(JSON.stringify(servers)), rendered: await table.locator("tr[data-row-id]").count(), tableNodes: await table.locator("*").count(), pageNodes: await page.locator("*").count(), readyMs: Date.now()-start } console.log("Optimizer full routes", JSON.stringify(profile)); writeFileSync(info.outputPath("optimizer-routes-profile.json"), JSON.stringify(profile, null, 2)) + const root = page.getByRole("region", { name: "Полные маршруты оптимизатора" }) + const rows = table.locator("tr[data-row-id]") + const next = root.getByRole("button", { name: "Следующая страница", exact: true }) + await next.click(); const previous = await rows.first().getAttribute("data-row-id") + await expect(rows.first().getByRole("cell").first()).toContainText("51") + const before = reads; await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect.poll(() => reads).toBeGreaterThan(before); await expect(rows.first()).toHaveAttribute("data-row-id", previous!) + for (let i=1; i<19; i++) await next.click() + await expect(next).toBeDisabled(); await expect(rows.first().getByRole("cell").first()).toContainText("951") + const minimum = await rows.last().getByRole("cell").nth(6).innerText() + await expect(rows.last().getByRole("cell").first()).toContainText("1000") + await root.getByRole("button", { name: "Свернуть", exact: true }).click(); await expect(rows).toHaveCount(5) + await root.getByRole("button", { name: "Открыть все 1000 комбинаций", exact: true }).click(); await expect(rows).toHaveCount(50) + await table.getByRole("button", { name: "Индекс", exact: true }).click() + await table.getByRole("button", { name: "Индекс", exact: true }).click() + await expect(rows.first().getByRole("cell").nth(6)).toHaveText(minimum) + expect(Number(await rows.last().getByRole("cell").first().innerText())).toBeGreaterThan(50) + await page.setViewportSize({ width: 320, height: 1000 }) + const scroll = root.locator('[data-slot="scroll-area-viewport"]') + await scroll.evaluate(el => { el.scrollLeft = el.scrollWidth }) + expect(await rows.first().getByRole("cell").first().evaluate(el => { const a=el.getBoundingClientRect(), v=el.closest('[data-slot="scroll-area-viewport"]')!.getBoundingClientRect(); return a.left>=v.left-1 && a.right<=v.right+1 })).toBe(true) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320) + await scroll.evaluate(el => { el.scrollLeft = 0 }) + await root.evaluate(el => el.scrollIntoView({ block: "start" })) + await page.addStyleTag({ content: "nextjs-portal{display:none!important}" }) + await page.screenshot({ path: info.outputPath("optimizer-routes-pages-320.png"), animations: "disabled" }) + await page.addScriptTag({ content: axe.source }) + expect((await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[aria-label="Полные маршруты оптимизатора"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }))).violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) }))).toEqual([]) + await next.click(); servers = servers.filter(server => server.id === 1 || server.id === 2 || server.id === 12).map(server => server.id === 1 ? { ...server, wanUplinks: server.wanUplinks!.slice(0, 1) } : server) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(rows).toHaveCount(1); await expect(rows.first().getByRole("cell").first()).toContainText("1") + expect(writes).toEqual([]) })