fix(ui): ограничить карточки оптимизатора с сохранением URL

This commit is contained in:
Denozordec committed 2026-10-04 18:03:59 +07:00
1 parent 7265709b1c
commit 059fd93520
10 files changed
+123 -11

No files matched your search

+3 -2
View File
@@ -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
</div>
{/* Per-home-router cards */}
{data?.homes.map((entry) => (
{data && <OptimizerHomesList homes={data.homes}>{entry => (
<HomeRouterCard
key={entry.home.id}
entry={entry}
@@ -1064,7 +1065,7 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer
onApply={applyRec}
simulation={!useLiveData}
/>
))}
)}</OptimizerHomesList>}
</div>
</fieldset>
+45
View File
@@ -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 <section aria-label="Домашние устройства оптимизатора" className="flex min-w-0 flex-col gap-4">
{controlled && <div className="rounded-xl border border-border bg-background p-4 flex min-w-0 flex-col gap-3">
<Input aria-label="Поиск домашних устройств оптимизатора" placeholder="Имя, IP или площадка…" value={query} onChange={event => change(event.target.value, 0)} />
<p role="status" className="text-xs text-muted-foreground">Показаны {filtered.length ? page * 5 + 1 : 0}–{Math.min((page + 1) * 5, filtered.length)} из {filtered.length} устройств. Всего {homes.length}.</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" className="min-h-11" disabled={!page} aria-label="Предыдущая страница домашних устройств" onClick={() => change(query, page - 1, true)}>Назад</Button>
<Button type="button" variant="outline" className="min-h-11" disabled={page >= pages - 1} aria-label="Следующая страница домашних устройств" onClick={() => change(query, page + 1, true)}>Далее</Button>
</div>
{!filtered.length && <p className="text-sm text-muted-foreground">Домашние устройства по запросу не найдены.</p>}
</div>}
{filtered.slice(page * 5, (page + 1) * 5).map(children)}
</section>
}
+2
View File
@@ -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.
+1 -1
View File
@@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+1 -1
View File
@@ -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). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
+9
View File
@@ -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.
@@ -0,0 +1,7 @@
{
"servers": 1000,
"bytes": 179347,
"homeCards": 5,
"pageNodes": 1870,
"readyMs": 1606
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+37 -5
View File
@@ -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([])
})
+18 -2
View File
@@ -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()
})