From 1cce712833c471eeadbed73bff9eda47dc9e21a5 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 4 Oct 2026 03:22:39 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20=D0=B1=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/server-tile-rail.tsx | 50 +++++++++++++-- docs/ux-ui-performance.md | 2 + docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 12 +++- docs/ux-ui-stage-12.md | 10 +++ tests/e2e/collector-probes-tables.spec.ts | 75 ++++++++++++++++++++++- 6 files changed, 141 insertions(+), 10 deletions(-) diff --git a/components/server-tile-rail.tsx b/components/server-tile-rail.tsx index afb1f8f2..7e5fde08 100644 --- a/components/server-tile-rail.tsx +++ b/components/server-tile-rail.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react" +import { useEffect, useMemo, useRef, useState, useId, type KeyboardEvent, type ReactNode } from "react" import type { ServerStatus, ServerType } from "@/lib/data" import { Flag } from "@/components/flag" import { StatusDot } from "@/components/status-dot" @@ -27,6 +27,9 @@ import { InputGroupInput, } from "@/components/ui/input-group" import { cn } from "@/lib/utils" +import { useTable } from "@tanstack/react-table" +import { DataGrid, DataGridPagination, dataGridFeatures } from "@/components/reui/data-grid" +import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru" import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react" /** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */ @@ -63,7 +66,7 @@ function typeBadgeLabel(type: ServerType): string { function ServerTypeBadge({ type }: { type: ServerType }) { return ( - + {typeBadgeLabel(type)} ) @@ -112,6 +115,7 @@ function ServerTileRail({ preserveSelection?: boolean className?: string }) { + const railId = useId() const [query, setQuery] = useState("") const q = query.trim().toLowerCase() const listRef = useRef(null) @@ -158,6 +162,35 @@ function ServerTileRail({ if (resolvedId && resolvedId !== selectedId) onSelect(resolvedId) }, [onSelect, resolvedId, selectedId]) + const orderedItems = useMemo(() => groups.flatMap(group => group.items), [groups]) + const table = useTable({ + features: dataGridFeatures, + data: orderedItems, + columns: [], + getRowId: item => item.id, + manualPagination: false, + autoResetPageIndex: false, + initialState: { pagination: { pageIndex: 0, pageSize: 50 } }, + }) + const { pageIndex, pageSize } = table.state.pagination + useEffect(() => { + const lastPage = Math.max(0, Math.ceil(orderedItems.length / pageSize) - 1) + if (pageIndex > lastPage) table.setPageIndex(lastPage) + }, [orderedItems.length, pageIndex, pageSize, table]) + const setPageIndex = table.setPageIndex + const selectedIndex = orderedItems.findIndex(item => item.id === resolvedId) + useEffect(() => { + setPageIndex(selectedIndex < 0 ? 0 : Math.floor(selectedIndex / pageSize)) + }, [resolvedId, selectedIndex, q, pageSize, setPageIndex]) + useEffect(() => { + const viewport = listRef.current?.closest('[data-slot="scroll-area-viewport"]') + viewport?.scrollTo({ top: 0 }) + listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }) + }, [pageIndex, resolvedId, q, pageSize]) + const visibleIds = new Set(table.getRowModel().rows.map(row => row.id)) + const visibleGroups = groups.map(group => ({ ...group, items: group.items.filter(item => visibleIds.has(item.id)) })).filter(group => group.items.length > 0) + const activeId = resolvedId === ALL_SERVERS_ID && showAllTile || visibleIds.has(resolvedId) ? resolvedId : undefined + const selectableIds = useMemo(() => { const ids: string[] = [] if (showAllTile) ids.push(ALL_SERVERS_ID) @@ -211,6 +244,7 @@ function ServerTileRail({ } return ( + `Страница устройств ${page}` } }}> @@ -249,12 +283,13 @@ function ServerTileRail({ className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы" - aria-activedescendant={resolvedId ? `server-tile-${resolvedId}` : undefined} + aria-activedescendant={activeId ? `${railId}-server-tile-${activeId}` : undefined} tabIndex={0} onKeyDown={handleListKeyDown} > {showAllTile ? ( onSelect(ALL_SERVERS_ID)} @@ -267,7 +302,7 @@ function ServerTileRail({ } /> ) : null} - {groups.map((group) => ( + {visibleGroups.map((group) => (
{groupBySite && group.site ? (

@@ -277,6 +312,7 @@ function ServerTileRail({ {group.items.map((item) => ( onSelect(item.id)} @@ -307,12 +343,15 @@ function ServerTileRail({ ) : null}

+ {(orderedItems.length > 50 || pageSize !== 50) && }
+
) } function ServerTileButton({ + id, item, selected, onSelect, @@ -321,6 +360,7 @@ function ServerTileButton({ showSite = true, icon, }: { + id: string item: ServerTileItem selected: boolean onSelect: () => void @@ -337,7 +377,7 @@ function ServerTileButton({ return ( ({ id: String(i + 1), srcServerId: String(i + 1), dstServerId: String(count + 1), srcInterface: "ether1", dstInterface: "ether1", protocol: "tcp", direction: "both", durationSec: "10", enabled: true, @@ -17,7 +18,7 @@ async function setup(page: Page, path: string, count = 12) { if (path === "/api/scheduler/status") return route.fulfill({ json: { jobs } }) if (path === "/api/scheduler/jobs/alert_engine/run-now") return route.fulfill({ json: { ok: true, result: { status: "ok", error: null, result: { v: 1, job: "alert_engine", sampledAt: "2026-10-03T00:00:00Z", rulesChecked: 0, standaloneFires: 0, groupFires: 0, skippedNoTelegram: false } }, status: { jobs } } }) return route.fulfill({ json: path === "/api/uptime/speed-probes" ? { probes } - : path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? Array.from({length:count+1},(_,i)=>({id:i+1,name:i({id:i+1,name:i { + await page.addInitScript(value => localStorage.setItem("rl-theme", value), theme) + await setup(page, "/probes?server=1000&keep=1", 1000) + const rail = page.locator("aside").filter({ has: page.getByRole("listbox", { name: "Серверы", exact: true }) }) + const list = rail.getByRole("listbox", { name: "Серверы", exact: true }) + await expect(list.getByRole("option")).toHaveCount(50) + await expect(list.getByRole("option", { selected: true })).toContainText("Источник 1000") + await expect(rail.getByText("951–1000 из 1001", { exact: true })).toBeVisible() + expect(await rail.locator("*").count()).toBeLessThan(1200) + await info.attach("large-rail-metrics", { body: JSON.stringify({ fixtureDevices: 1001, renderedOptions: await list.getByRole("option").count(), railNodes: await rail.locator("*").count(), domNodes: await page.locator("*").count() }), contentType: "application/json" }) + await list.focus() + await page.keyboard.press("End") + await expect(page).toHaveURL(url => url.searchParams.get("server") === "1001" && url.searchParams.get("keep") === "1") + await expect(list.getByRole("option")).toHaveCount(1) + await expect(list.getByRole("option", { selected: true })).toContainText("Цель") + await page.keyboard.press("Home") + await expect(page).toHaveURL(url => url.searchParams.get("server") === "1" && url.searchParams.get("keep") === "1") + await expect(list.getByRole("option")).toHaveCount(50) + await page.keyboard.press("ArrowDown") + await expect(page).toHaveURL(url => url.searchParams.get("server") === "2" && url.searchParams.get("keep") === "1") + await expect(list.getByRole("option", { selected: true })).toContainText("Источник 2") + await rail.getByRole("button", { name: "Следующие устройства", exact: true }).click() + await expect(list.getByRole("option").first()).toContainText("Источник 51") + await page.getByRole("button", { name: "Обновить устройства диагностики", exact: true }).click() + await expect(page.getByRole("button", { name: "Обновить устройства диагностики", exact: true })).toBeEnabled() + await expect(rail.getByText("51–100 из 1001", { exact: true })).toBeVisible() + const search = rail.getByRole("textbox", { name: "Поиск сервера", exact: true }) + await search.fill("Источник 1000") + await expect(list.getByRole("option")).toHaveCount(1) + await list.getByRole("option").click() + await expect(page).toHaveURL(url => url.searchParams.get("server") === "1000" && url.searchParams.get("keep") === "1") + await search.fill("") + await expect(list.getByRole("option", { selected: true })).toContainText("Источник 1000") + await page.setViewportSize({ width: 320, height: 900 }) + await page.getByRole("button", { name: "Серверы", exact: true }).click() + const sheet = page.getByRole("dialog") + await expect(sheet.getByRole("option")).toHaveCount(50) + await expect(sheet.getByRole("option", { selected: true })).toContainText("Источник 1000") + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + expect(await sheet.getByRole("button", { name: "Следующие устройства", exact: true }).evaluate(el => el.getBoundingClientRect().right <= innerWidth)).toBe(true) + expect(await sheet.getByRole("listbox", { name: "Серверы", exact: true }).evaluate(el => { const id = el.getAttribute("aria-activedescendant"); return !!id && el.contains(document.getElementById(id)) })).toBe(true) + await page.addScriptTag({ content: axe.source }) + const audit = await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[role="dialog"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } })) + await info.attach("large-rail-axe", { body: JSON.stringify(audit), contentType: "application/json" }) + expect(audit.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }))).toEqual([]) + await page.screenshot({ path: `test-results/large-server-rail-mobile-${theme}.png`, animations: "disabled" }) + await page.keyboard.press("Escape") + await expect(page.getByRole("button", { name: "Серверы", exact: true })).toBeFocused() +}) + +test("Large server rail: выбранное устройство и граница площадок", async ({ page }) => { + await setup(page, "/probes?server=1000", 1000, true) + const rail = page.locator("aside").filter({ has: page.getByRole("listbox", { name: "Серверы", exact: true }) }) + const list = rail.getByRole("listbox", { name: "Серверы", exact: true }) + await expect(list.getByRole("option")).toHaveCount(1) + await expect(list.getByRole("option", { selected: true })).toContainText("Источник 1000") + await expect(rail.getByText("Площадка B", { exact: true })).toBeVisible() + await list.focus() + await page.keyboard.press("End") + await expect(list.getByRole("option", { selected: true })).toContainText("Цель") + await expect(list.getByRole("option")).toHaveCount(50) + await expect(rail.getByText("Площадка A", { exact: true })).toBeVisible() + await expect(rail.getByText("Площадка B", { exact: true })).toBeVisible() + await page.keyboard.press("Home") + await expect(list.getByRole("option", { selected: true })).toContainText("Источник 1") + await expect(list.getByRole("option")).toHaveCount(50) +})