diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index 6970d31a..fbac66dd 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -903,6 +903,7 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer { + const value = query.trim().toLowerCase() + return value ? rows.filter(row => `${row.interface} ${row.id}`.toLowerCase().includes(value)) : rows + }, [rows, query]) const columns = useMemo[]>( () => [ { id: "interface", + size: 120, accessorKey: "interface", header: ({ column }) => ( ), - cell: ({ row }) => {row.original.interface}, + cell: ({ row }) => {row.original.interface}, meta: { headerTitle: "Интерфейс", headerClassName: DATA_GRID_CELL_PAD_FIRST, @@ -65,6 +72,7 @@ function RouteOptimizerOspfPreviewDataGrid({ }, { id: "cost", + size: 144, accessorKey: "currentCost", header: ({ column }) => , cell: ({ row }) => { @@ -89,6 +97,7 @@ function RouteOptimizerOspfPreviewDataGrid({ }, { id: "score", + size: 96, accessorKey: "score", header: ({ column }) => , cell: ({ row }) => {row.original.score}, @@ -96,6 +105,7 @@ function RouteOptimizerOspfPreviewDataGrid({ }, { id: "pingMs", + size: 288, accessorKey: "pingMs", header: ({ column }) => , cell: ({ row }) =>
{row.original.pingMs}ms

{describeObserved(row.original.observed?.ping,"мс")}

, @@ -103,6 +113,7 @@ function RouteOptimizerOspfPreviewDataGrid({ }, { id: "speed", + size: 336, header: () => Speed (dl/ul) · модель, enableSorting: false, cell: ({ row }) => { @@ -121,12 +132,20 @@ function RouteOptimizerOspfPreviewDataGrid({ const table = useTable({ features: dataGridFeatures, - manualPagination: true, - data: rows, + manualPagination: false, + autoResetPageIndex: false, + initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["interface"], end: [] } }, + data: filteredRows, columns, getRowId: (row) => row.id, }) + const { pageIndex, pageSize } = table.state.pagination + useEffect(() => { + const lastPage = Math.max(0, Math.ceil(filteredRows.length / pageSize) - 1) + if (pageIndex > lastPage) table.setPageIndex(lastPage) + }, [filteredRows.length, pageIndex, pageSize, table]) + if (error && rows.length === 0) { return (

Ошибка preview: {error}

@@ -144,18 +163,28 @@ function RouteOptimizerOspfPreviewDataGrid({ } return ( - <> +
{error &&

Ошибка preview: {error}. Последний снимок; актуальность неизвестна.

} {loading && rows.length > 0 &&

Обновление preview OSPF…

} +
+ { setQuery(event.target.value); table.setPageIndex(0) }} /> +

Найдено {filteredRows.length} из {rows.length} интерфейсов. Фильтр меняет только отображение preview.

+
50 || pageSize !== 50} + emptyMessage="Нет интерфейсов по запросу" isLoading={loading && rows.length === 0} loadingMode="skeleton" tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }} - tableLayout={{ dense: true }} - /> - + tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }} + > + + + + +
) } diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index c32f51a1..e3174c4f 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 —9 файлов после12.3.49. Это не9 доказанных дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API. +Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —8 файлов после12.3.53. Это не8 доказанных дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API. | Файл | Источник и необходимая проверка | | --- | --- | @@ -24,12 +24,11 @@ | [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-ospf-preview-data-grid.tsx](../components/data-grids/route-optimizer-ospf-preview-data-grid.tsx) | Полный interfaces из preview; проверить global сортировку/unknown и apply всего подтверждённого preview. | | [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 и связывание с графиками. | -Очередь: OSPF topology/node details → optimizer preview/комбинаторика. Scheduler11, traffic cap50 и commRecs24 проверять на реальной границе, не искусственно подставлять1000 в недоступный DTO. Demo и неиспользуемый Subusers отделены от live приёмки. Все новые профили должны сохранять search/sort/refresh/clamp, точный target и права,320px и URL scope. +Очередь: many HomeRouterCard/optimizer матрицы/комбинаторика. OSPF topology/node details/preview приняты локально в12.3.50–53. Scheduler11, traffic cap50 и commRecs24 проверять на реальной границе, не искусственно подставлять1000 в недоступный DTO. Demo и неиспользуемый Subusers отделены от live приёмки. Все новые профили должны сохранять search/sort/refresh/clamp, точный target и права,320px и URL scope. Принятые после ранней карты: OSPF routes/BFD, WireGuard interfaces/peers/detail, VXLAN/GRE tunnels. Pagination сохраняет полный DTO в памяти и не доказывает серверную производительность. Текущий журнал: [ux-ui-progress.md](ux-ui-progress.md). @@ -100,3 +99,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич 12.3.51: OSPF node=1/1000 neighbors+1000 interfaces →50+50 rows/635 panel nodes/2335 page DOM.4+15+1 browser/type/lint/build. Search/full counts/refresh/clamp/320px/axe приняты; parallel link labels далее12.3.52. 12.3.52: OSPF parallel1000 between2 nodes →8 raw edges per page/1 visual line, полный источник/state/cost сохранён.9+1 browser/type/lint/build; screenshot1440 принят. Далее optimizer preview. + +12.3.53: OSPF preview1000 interfaces/245293 bytes →50 rows/924 region nodes/1610 page DOM.47+16 main/8 context/type/lint/build. Полный POST/receipt1000 при search1, sort/cache/clamp/320px приняты.8 manualPagination:true; далее many HomeRouterCard/матрицы. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 2c07f2bf..b0145d7b 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-04: 12.3.52 принят локально — параллельные связи OSPF объединены визуально без общего выдуманного cost/state;9+1 browser/type/lint/build. Далее12.3.53 — большой OSPF preview оптимизатора; затем остальные матрицы/общая регрессия. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.53 принят локально: OSPF preview оптимизатора1000→50 строк, полный поиск/sort/apply/refresh/cache/clamp/320px.47+16 main/8 context/type/lint/build. Далее12.3.54 — профиль множества HomeRouterCard; затем ограничение карточек/матриц и общая регрессия.8 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 c1b1689d..c0a47c13 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-04: 12.3.52 принят локально — параллельные связи OSPF объединены визуально без общего выдуманного cost/state;9+1 browser/type/lint/build. Далее12.3.53 — большой OSPF preview оптимизатора; затем остальные матрицы/общая регрессия. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.53 принят локально: OSPF preview оптимизатора1000→50 строк, полный поиск/sort/apply/refresh/cache/clamp/320px.47+16 main/8 context/type/lint/build. Далее12.3.54 — профиль множества HomeRouterCard; затем ограничение карточек/матриц и общая регрессия.8 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 f8989250..e6e0b779 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -621,3 +621,12 @@ TopologyGraph группирует видимые edges по неориенти 9/9 browser (large topology/details/identity/states/layout/keyboard/reduced motion) и финальный parallel1/1 прошли.1000 links between2 nodes, page8 links →одна visual group, aria-label Full/cost1 и Down/cost2, подпись8 связей. TypeScript без incremental/ESLint страницы/scope/теста и окончательный production build прошли. [Группировка1440px](ux-ui/screens/ospf-parallel-links-1440.png) сохранена и просмотрена; PNG после проверки показал недостаточный opacity, исправлен и повторно проверен. Следующий12.3.53: OSPF preview оптимизатора1000 rows/полная apply-ветка при pagination/search; затем матрицы и many HomeRouterCard.9 manualPagination:true specialized grids остаются. Общая main/context регрессия после delta и внешняя12.5 отдельно. Локальный коммит без push. + + +## 12.3.53 — большой OSPF preview оптимизатора (2026-10-04) + +Штатные ReUI pages50/client sorting/clamp и ScrollArea, закреплённый interface120px оставляет место cost на320px. Именованная область preview включает поиск полного массива id/interface, полные counts и явное пояснение, что фильтр меняет только отображение. Контекстный key server/weight сбрасывает поиск/страницу при смене устройства; фоновый refresh сохраняет их. Применение не использует фильтр/видимую страницу, существующий POST pingWeight и строгий receipt сохранены. + +47/47 профильных main,16/16 final preview/large/new context-search и8/8 backend/mode context прошли.1000 interfaces/245293 JSON bytes →50 rows/924 preview region DOM/1610 page DOM. Search ether1000→1 row/receipt1000 applied; Cost sort full array, page2/refresh/cache503/disabled apply/shrink1000→1/server+keep,320px/pinning/axe, смена server очищает no-match query. TypeScript без incremental/полный ESLint grid/page/двух тестов/production build прошли. [Профиль](ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json), [320px](ux-ui/screens/optimizer-ospf-preview-320.png) сохранены и просмотрены. API fixture receipt исправлен по существующему контракту currentCost=from; контракт не ослаблен. Это проверка HTTP live-ветки с подменёнными ответами, не реальное применение RouterOS. Полный массив остаётся в памяти. + +Осталось8 manualPagination:true specialized grids. Следующий12.3.54 — исходный профиль1000 HomeRouterCard с полным servers DTO; затем bounded cards/full-route/wan-matrix, честность24 commRecs. Общая main/context после delta и внешняя12.5 отдельно. Локальный коммит без push. diff --git a/docs/ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json b/docs/ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json new file mode 100644 index 00000000..7de8f725 --- /dev/null +++ b/docs/ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json @@ -0,0 +1,7 @@ +{ + "count": 1000, + "bytes": 245293, + "rendered": 50, + "gridNodes": 924, + "pageNodes": 1610 +} \ No newline at end of file diff --git a/docs/ux-ui/screens/optimizer-ospf-preview-320.png b/docs/ux-ui/screens/optimizer-ospf-preview-320.png new file mode 100644 index 00000000..6de6483d Binary files /dev/null and b/docs/ux-ui/screens/optimizer-ospf-preview-320.png differ diff --git a/tests/e2e/large-optimizer-ospf-preview.spec.ts b/tests/e2e/large-optimizer-ospf-preview.spec.ts new file mode 100644 index 00000000..3776afef --- /dev/null +++ b/tests/e2e/large-optimizer-ospf-preview.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test" +import { writeFileSync } from "node:fs" +import axe from "axe-core" +import { optimizerOspfPreviewSchema } from "../../lib/route-optimizer-response" + +test("Оптимизатор1000 preview: полный apply при поиске, страницы и кэш", async ({ page }, info) => { + test.setTimeout(90000) + let interfaces = Array.from({ length: 1000 }, (_, i) => ({ id: `i${i+1}`, interface: `ether${i+1}`, currentCost: i+1, optimalCost: i+1001, score: i+1, pingMs: 10, dlMbps: 100, ulMbps: 50 })) + let applied = false, failed = false, previews = 0, writes = 0 + const preview = () => optimizerOspfPreviewSchema.parse({ serverId: 1, serverName: "Роутер 1", pingWeight: 60, interfacesTotal: interfaces.length, changedCount: applied ? 0 : interfaces.length, unchangedCount: applied ? interfaces.length : 0, interfaces: interfaces.map(row => applied ? { ...row, currentCost: row.optimalCost } : row), changes: applied ? [] : interfaces }) + 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.endsWith("/ospf/optimize/preview")) { previews++; return r.fulfill(failed ? { status: 503, json: { error: "Preview недоступен1000" } } : { json: preview() }) } + if (path.endsWith("/ospf/optimize")) { + writes++; expect(r.request().postDataJSON()).toEqual({ pingWeight: 60 }); applied = true + return r.fulfill({ json: { serverId: 1, serverName: "Роутер 1", pingWeight: 60, optimizedCount: interfaces.length, applied: interfaces.map(row => ({ interface: row.interface, from: row.currentCost, to: row.optimalCost })), interfaces } }) + } + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [{ id: 1, name: "Роутер 1", host: "192.0.2.1", type: "home-router", country: "RU", enabled: true, status: null }] : path === "/api/filters/rules" ? { rulesets: [] } : path === "/api/uptime/speed-probes" ? { probes: [] } : path === "/api/ospf/catalog" ? { interfaces: [], instances: [], neighbors: [], bfdSessions: [], routes: [], failures: [] } : {} }) + }) + await page.goto("/route-optimizer?server=1&keep=1") + const grid = page.getByRole("table").filter({ hasText: "Speed (dl/ul)" }) + const rows = grid.locator("tr[data-row-id]") + await expect(rows).toHaveCount(50) + await expect(rows.first()).toHaveAttribute("data-row-id", "i1") + const root = page.getByRole("region", { name: "Preview интерфейсов OSPF", exact: true }) + const profile = { count: interfaces.length, bytes: Buffer.byteLength(JSON.stringify(preview())), rendered: await rows.count(), gridNodes: await root.locator("*").count(), pageNodes: await page.locator("*").count() } + console.log("Optimizer preview", JSON.stringify(profile)); writeFileSync(info.outputPath("optimizer-ospf-preview-profile.json"), JSON.stringify(profile, null, 2)) + await root.getByRole("button", { name: "Следующая страница", exact: true }).click() + await expect(rows.first()).toHaveAttribute("data-row-id", "i51") + const before = previews; await page.getByRole("button", { name: "Обновить preview OSPF", exact: true }).click() + await expect.poll(() => previews).toBeGreaterThan(before); await expect(rows.first()).toHaveAttribute("data-row-id", "i51") + const search = page.getByRole("textbox", { name: "Поиск интерфейсов preview OSPF" }) + await search.fill("ether1000"); await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "i1000") + await expect(page.getByRole("status").filter({ hasText: "Фильтр меняет только отображение" })).toContainText("Найдено 1 из 1000") + await page.getByRole("button", { name: "Оптимизировать OSPF", exact: true }).click() + await expect(page.getByRole("status").filter({ hasText: "Последнее подтверждение API" })).toContainText("изменено интерфейсов — 1000") + expect(writes).toBe(1); await expect(page.getByText("0 из 1000 изменятся", { exact: true })).toBeVisible() + await expect(rows).toHaveCount(1) + await search.fill(""); await expect(rows).toHaveCount(50) + await grid.getByRole("button", { name: "Cost", exact: true }).click() + await expect(rows.first()).toHaveAttribute("data-row-id", "i1000") + await root.getByRole("button", { name: "Следующая страница", exact: true }).click() + const previous = await rows.first().getAttribute("data-row-id") + failed = true; await page.getByRole("button", { name: "Обновить preview OSPF", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Последний снимок" })).toBeVisible() + await expect(rows.first()).toHaveAttribute("data-row-id", previous!) + await expect(page.getByRole("button", { name: "Оптимизировать OSPF", exact: true })).toBeDisabled() + 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 grid.evaluate(el => el.scrollIntoView({ block: "start" })) + await page.addStyleTag({ content: "nextjs-portal{display:none!important}" }) + await page.screenshot({ path: info.outputPath("optimizer-ospf-preview-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="Preview интерфейсов OSPF"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }))).violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) }))).toEqual([]) + failed = false; interfaces = interfaces.slice(0, 1) + await page.getByRole("button", { name: "Обновить preview OSPF", exact: true }).click() + await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "i1") + await expect(page).toHaveURL(url => url.searchParams.get("server") === "1" && url.searchParams.get("keep") === "1") +}) diff --git a/tests/e2e/route-optimizer-ospf-preview.spec.ts b/tests/e2e/route-optimizer-ospf-preview.spec.ts index eb067d9b..237115cc 100644 --- a/tests/e2e/route-optimizer-ospf-preview.spec.ts +++ b/tests/e2e/route-optimizer-ospf-preview.spec.ts @@ -63,3 +63,15 @@ for(const server of ["999","3","abc","01",""])test("Оптимизатор: не await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible() await expect(page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})).toBeEnabled() }) + + +test("Оптимизатор: поиск preview сбрасывается при смене устройства", async ({ page }) => { + await base(page); await page.goto("/route-optimizer?server=1&keep=value") + const search = page.getByRole("textbox", { name: "Поиск интерфейсов preview OSPF" }) + await search.fill("несуществующий") + await expect(page.getByText("Нет интерфейсов по запросу", { exact: true })).toBeVisible() + await page.getByRole("combobox", { name: "Устройство OSPF", exact: true }).selectOption("2") + await expect(search).toHaveValue("") + await expect(page.getByRole("table").filter({ hasText: "Speed (dl/ul)" }).locator("tr[data-row-id]")).toHaveCount(1) + await expect(page).toHaveURL(url => url.searchParams.get("server") === "2" && url.searchParams.get("keep") === "value") +})