diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index fbac66dd..6fbcc1bc 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -6,6 +6,7 @@ import {useSearchParams} from "next/navigation" import {can} from "@/lib/auth" import {ospfCatalogSchema,ospfOptimizeSchema} from "@/lib/ospf-response" import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema,optimizerOspfPreviewSchema,type OptimizerOspfPreview} from "@/lib/route-optimizer-response" +import { OptimizerHomesList } from "@/components/optimizer-homes-list" import { PageHeader } from "@/components/page-header" import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid" import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid" @@ -1052,7 +1053,7 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer {/* Per-home-router cards */} - {data?.homes.map((entry) => ( + {data && {entry => ( - ))} + )}} diff --git a/components/optimizer-homes-list.tsx b/components/optimizer-homes-list.tsx new file mode 100644 index 00000000..007782d1 --- /dev/null +++ b/components/optimizer-homes-list.tsx @@ -0,0 +1,45 @@ +"use client" + +import { useEffect, useMemo, type ReactNode } from "react" +import { useSearchParams } from "next/navigation" +import type { HomeEntry } from "@/lib/route-optimizer-data" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export function OptimizerHomesList({ homes, children }: { homes: HomeEntry[]; children: (entry: HomeEntry) => ReactNode }) { + const params = useSearchParams() + const query = params.get("home_q") ?? "" + const rawPage = params.get("home_page") ?? "1" + const requestedPage = /^[1-9]\d*$/.test(rawPage) && Number.isSafeInteger(Number(rawPage)) ? Number(rawPage) - 1 : 0 + const filtered = useMemo(() => { + const value = query.trim().toLowerCase() + return value ? homes.filter(({ home }) => `${home.label} ${home.id} ${home.ip} ${home.site} ${home.country}`.toLowerCase().includes(value)) : homes + }, [homes, query]) + const pages = Math.max(1, Math.ceil(filtered.length / 5)) + const page = Math.min(requestedPage, pages - 1) + function change(queryValue: string, pageValue: number, push = false) { + const url = new URL(window.location.href) + if (queryValue) url.searchParams.set("home_q", queryValue); else url.searchParams.delete("home_q") + if (pageValue) url.searchParams.set("home_page", String(pageValue + 1)); else url.searchParams.delete("home_page") + window.history[push ? "pushState" : "replaceState"](null, "", url.pathname + url.search + url.hash) + } + useEffect(() => { + if (requestedPage < pages) return + const url = new URL(window.location.href) + if (page) url.searchParams.set("home_page", String(page + 1)); else url.searchParams.delete("home_page") + window.history.replaceState(null, "", url.pathname + url.search + url.hash) + }, [requestedPage, pages, page]) + const controlled = homes.length > 5 || !!query || requestedPage > 0 + return
+ {controlled &&
+ change(event.target.value, 0)} /> +

Показаны {filtered.length ? page * 5 + 1 : 0}–{Math.min((page + 1) * 5, filtered.length)} из {filtered.length} устройств. Всего {homes.length}.

+
+ + +
+ {!filtered.length &&

Домашние устройства по запросу не найдены.

} +
} + {filtered.slice(page * 5, (page + 1) * 5).map(children)} +
+} diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index f2866ff5..89237bf3 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -103,3 +103,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич 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/матрицы. 12.3.54:1000 HomeRouterCard/179347 bytes →40668 page DOM/11496ms dev ready.1 browser/test lint, baseline без WAN не означает принятую производительность. Далее bounded cards12.3.55. + +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. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 0ae08db8..d7575f5b 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-04: 12.3.54 сохранён — baseline1000 HomeRouterCard/40668 DOM/11496ms dev ready;1 browser/test lint. Это не приёмка. Далее12.3.55 — ограничить карточки с полным поиском/URL/refresh/clamp; затем матрицы/общая регрессия. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.55 принят локально:1000 HomeRouterCard→5/1870 page DOM, полный поиск/URL/reload/back/refresh/cache/clamp/320px/axe; закрепление Community сохраняется.23+8 main/8 context/type/lint/build. Далее12.3.56 — исходный профиль полной таблицы маршрутов; затем её ограничение/матрицы/общая регрессия. Внешняя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 9cb83bea..8180c90d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-04: 12.3.54 сохранён — baseline1000 HomeRouterCard/40668 DOM/11496ms dev ready;1 browser/test lint. Это не приёмка. Далее12.3.55 — ограничить карточки с полным поиском/URL/refresh/clamp; затем матрицы/общая регрессия. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.55 принят локально:1000 HomeRouterCard→5/1870 page DOM, полный поиск/URL/reload/back/refresh/cache/clamp/320px/axe; закрепление Community сохраняется.23+8 main/8 context/type/lint/build. Далее12.3.56 — исходный профиль полной таблицы маршрутов; затем её ограничение/матрицы/общая регрессия. Внешняя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 bb3d4314..c9cdf7c8 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -637,3 +637,12 @@ TopologyGraph группирует видимые edges по неориенти Полный optimizerServersSchema1000 home-router/179347 bytes, без подтверждённых WAN/probes/filters.1/1 browser и ESLint теста прошли.1000 HomeRouterCard/40668 page DOM/11496ms dev ready; document320px не шире viewport. [Профиль](ux-ui/acceptance/optimizer-homes-baseline-2026-10-04.json), [исходный320px](ux-ui/screens/optimizer-homes-baseline-320.png) сохранены. Это baseline, не приёмка производительности; dev latency не production benchmark. Нет WAN — это валидная live ветка, не подмена неизвестных данных нулями. Продуктовый код не менялся, build повторно не нужен. Следующий12.3.55: ограниченный DOM HomeRouterCard/поиск полного каталога/URL/refresh/clamp/320px. Расчёт полного массива остаётся отдельным CPU риском; раскрытые full-routes и WAN dynamic columns требуют других профилей. Общая регрессия и внешняя12.5 отдельно. Локальный коммит без push. + + +## 12.3.55 — ограниченные карточки домашних устройств (2026-10-04) + +OptimizerHomesList показывает5 карточек на страницу, поиск по полным label/id/IP/site/country. home_q/home_page в URL сохраняют server/keep/hash; поиск replaceState/reset page, pagination pushState, clamp после нового полного снимка нормализует URL. Малый каталог до5 без фильтра сохраняет прежнюю композицию. Расчёт и полный DTO не обрезаются; глобальные pinned/applied Sets остаются у родителя. Карточка при уходе со страницы перемонтируется, её локальная вкладка сбрасывается — восстановление вкладки отдельно не принято. + +23 профильных main прошли; единственный сбой нового pin-сценария был неверным selector теста (row key home::community::index); исправлен, финальный suite8/8 passed.8/8 backend/mode context, TypeScript без incremental/полный ESLint страницы/компонента/тестов/production build прошли.1000 home-router/179347 bytes →5 cards/1870 page DOM/1606ms dev ready вместо40668/11496ms. Проверены page200/refresh, search1000/reload/back/keep/server, cache503/full snapshot, shrink1000→1/URL clamp, pin Community→page2→page1,320px/HTML pagination44px/axe. [Профиль](ux-ui/acceptance/optimizer-homes-pages-2026-10-04.json), [320px](ux-ui/screens/optimizer-homes-pages-320.png) сохранены и просмотрены. Dev latency не production benchmark; native OSPF device select всё ещё содержит1000 options. + +Следующий12.3.56: полный профиль WAN10×JH10×Exit10/1000 routes и раскрытой таблицы. WAN matrix columns/полный расчёт combinatorics и24 commRecs остаются самостоятельными ограничениями.8 specialized manualPagination:true остаются. Общая регрессия после delta и внешняя12.5 отдельно. Локальный коммит без push. diff --git a/docs/ux-ui/acceptance/optimizer-homes-pages-2026-10-04.json b/docs/ux-ui/acceptance/optimizer-homes-pages-2026-10-04.json new file mode 100644 index 00000000..84f14a5d --- /dev/null +++ b/docs/ux-ui/acceptance/optimizer-homes-pages-2026-10-04.json @@ -0,0 +1,7 @@ +{ + "servers": 1000, + "bytes": 179347, + "homeCards": 5, + "pageNodes": 1870, + "readyMs": 1606 +} \ No newline at end of file diff --git a/docs/ux-ui/screens/optimizer-homes-pages-320.png b/docs/ux-ui/screens/optimizer-homes-pages-320.png new file mode 100644 index 00000000..2644a817 Binary files /dev/null and b/docs/ux-ui/screens/optimizer-homes-pages-320.png differ diff --git a/tests/e2e/large-optimizer-homes.spec.ts b/tests/e2e/large-optimizer-homes.spec.ts index 5bc02fbf..31669c01 100644 --- a/tests/e2e/large-optimizer-homes.spec.ts +++ b/tests/e2e/large-optimizer-homes.spec.ts @@ -1,22 +1,54 @@ 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 домашних устройств: профиль полного экрана", async ({ page }, info) => { test.setTimeout(90000) - const servers = optimizerServersSchema.parse(Array.from({ length: 1000 }, (_, i) => ({ id: i+1, name: `Домашний роутер ${i+1}`, host: `10.${Math.floor(i/256)}.${i%256}.1`, type: "home-router", country: "RU", site: "MSK", enabled: true, status: null }))) + let servers = optimizerServersSchema.parse(Array.from({ length: 1000 }, (_, i) => ({ id: i+1, name: `Домашний роутер ${i+1}`, host: `10.${Math.floor(i/256)}.${i%256}.1`, type: "home-router", country: "RU", site: "MSK", enabled: true, status: null }))) + let failed = false + 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 (r.request().method() !== "GET" && !path.endsWith("/preview")) writes.push(path) + if (path === "/api/servers" && failed) return r.fulfill({ status: 503, json: { error: "Каталог домашних устройств недоступен" } }) 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: servers[0].name, pingWeight: 60, interfacesTotal: 0, changedCount: 0, unchangedCount: 0, interfaces: [], changes: [] } : {} }) }) - const start = Date.now(); await page.goto("/route-optimizer?server=1&keep=1") + const start = Date.now(); await page.goto("/route-optimizer?server=1&keep=1&home_page=200") const cards = page.getByRole("button", { name: "WAN × JH (0×0)", exact: true }) - await expect(cards).toHaveCount(1000) + await expect(cards).toHaveCount(5) + const scope = page.getByRole("region", { name: "Домашние устройства оптимизатора" }) + await expect(scope.getByRole("status")).toContainText("996–1000 из 1000") + await expect(scope.getByText("Домашний роутер 996", { exact: true })).toBeVisible() const profile = { servers: servers.length, bytes: Buffer.byteLength(JSON.stringify(servers)), homeCards: await cards.count(), pageNodes: await page.locator("*").count(), readyMs: Date.now()-start } - console.log("Optimizer homes", JSON.stringify(profile)); writeFileSync(info.outputPath("optimizer-homes-profile.json"), JSON.stringify(profile, null, 2)) + console.log("Optimizer homes pages", JSON.stringify(profile)); writeFileSync(info.outputPath("optimizer-homes-profile.json"), JSON.stringify(profile, null, 2)) + await scope.getByRole("button", { name: "Предыдущая страница домашних устройств" }).click() + await expect(scope.getByRole("status")).toContainText("991–995 из 1000") + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(scope.getByRole("status")).toContainText("991–995 из 1000") + const search = scope.getByRole("textbox", { name: "Поиск домашних устройств оптимизатора" }) + await search.fill("Домашний роутер 1000"); await expect(cards).toHaveCount(1) + await expect(scope.getByText("Домашний роутер 1000", { exact: true })).toBeVisible() + await page.reload(); await expect(cards).toHaveCount(1); await expect(search).toHaveValue("Домашний роутер 1000") + await expect(page).toHaveURL(url => url.searchParams.get("keep") === "1" && url.searchParams.get("server") === "1") + await search.fill(""); await expect(cards).toHaveCount(5) + await expect(scope.getByText("Домашний роутер 1", { exact: true })).toBeVisible() + await page.goBack(); await expect(scope.getByRole("status")).toContainText("996–1000 из 1000") + failed = true; await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Последний полный снимок" })).toBeVisible() + await expect(scope.getByRole("status")).toContainText("996–1000 из 1000") await page.addStyleTag({ content: "nextjs-portal{display:none!important}" }) await page.setViewportSize({ width: 320, height: 1000 }) expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320) - await page.screenshot({ path: info.outputPath("optimizer-homes-baseline-320.png"), animations: "disabled" }) + await scope.evaluate(el => el.scrollIntoView({ block: "start" })) + await page.screenshot({ path: info.outputPath("optimizer-homes-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([]) + for (const name of ["Предыдущая страница домашних устройств", "Следующая страница домашних устройств"]) expect((await scope.getByRole("button", { name }).boundingBox())!.height).toBeGreaterThanOrEqual(44) + failed = false; servers = servers.slice(0, 1) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(cards).toHaveCount(1); await expect(scope.getByText("Домашний роутер 1", { exact: true })).toBeVisible() + await expect(page).toHaveURL(url => !url.searchParams.has("home_page") && url.searchParams.get("server") === "1" && url.searchParams.get("keep") === "1") + expect(writes).toEqual([]) }) diff --git a/tests/e2e/route-optimizer-tables.spec.ts b/tests/e2e/route-optimizer-tables.spec.ts index 61c571b1..26a2d93c 100644 --- a/tests/e2e/route-optimizer-tables.spec.ts +++ b/tests/e2e/route-optimizer-tables.spec.ts @@ -8,7 +8,7 @@ const servers: OptimizerApiServer[] = (["home-router", "jump-host", "exit-node"] ...(i === 0 ? { wanUplinks: ids.map(id => ({ id: `wan${id}`, name: `WAN${id}`, isp: "ISP", iface: `ether${id}`, ip: `198.51.100.${id}`, maxDl: 100, maxUl: 100 })) } : {}), })) -async function setup(page: Page, preview: "ok" | "empty" | "error" = "ok", missingExit = false) { +async function setup(page: Page, preview: "ok" | "empty" | "error" = "ok", missingExit = false, manyHomes = false) { await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/api/**", route => { const path = new URL(route.request().url()).pathname @@ -17,7 +17,7 @@ async function setup(page: Page, preview: "ok" | "empty" | "error" = "ok", missi const interfaces = preview === "empty" ? [] : ids.map(i => ({ id: String(i), interface: `ether${i}`, currentCost: i, optimalCost: 20, score: i, pingMs: i, dlMbps: 100, ulMbps: 50 })) return route.fulfill({ json: {serverId:1,serverName:"Роутер 1",pingWeight:60,unchangedCount:0, interfaces, changes: interfaces, changedCount: interfaces.length, interfacesTotal: interfaces.length } }) } - return route.fulfill({ json: path === "/api/servers" ? servers.filter(server => !missingExit || server.type !== "exit-node") + return route.fulfill({ json: path === "/api/servers" ? [...servers.filter(server => !missingExit || server.type !== "exit-node"), ...(manyHomes ? Array.from({ length: 5 }, (_, i) => ({ ...servers[0], id: i+4, name: `Другой дом ${i+4}`, wanUplinks: [] })) : [])] : path === "/api/auth/config" ? { required: false } : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: ids.map(i => ({ id: String(i), community: `65001:${i}`, communityName: `Сервис ${i}`, gateway: "192.0.2.2", gatewayTunnelId: "", description: "" })) }] } @@ -84,3 +84,19 @@ test("Оптимизатор: без ExitNode отсутствующие инд await expect(grid).not.toContainText("0 б.") await expect(grid.getByRole("button",{name:"Симулировать",exact:true})).toHaveCount(0) }) + + +test("Оптимизатор: закрепление Community сохраняется между страницами устройств", async ({ page }) => { + await setup(page, "ok", false, true) + const scope = page.getByRole("region", { name: "Домашние устройства оптимизатора" }) + await page.getByRole("button", { name: "BGP community (12)", exact: true }).click() + const rec = scope.getByRole("table").filter({ hasText: "Community" }).locator('tr[data-row-id="1::65001:1::0"]') + await rec.getByRole("button", { name: "Закрепить", exact: true }).click() + await expect(rec.getByRole("button", { name: "Открепить", exact: true })).toBeVisible() + await scope.getByRole("button", { name: "Следующая страница домашних устройств" }).click() + await expect(scope.getByRole("status")).toContainText("6–6 из 6") + await expect(scope.getByText("Другой дом 8", { exact: true })).toBeVisible() + await scope.getByRole("button", { name: "Предыдущая страница домашних устройств" }).click() + await page.getByRole("button", { name: "BGP community (12)", exact: true }).click() + await expect(rec.getByRole("button", { name: "Открепить", exact: true })).toBeVisible() +})