fix(ui): ограничить раскрытие большого списка пиров WireGuard

This commit is contained in:
Denozordec committed 2026-10-04 12:16:34 +07:00
1 parent 747f544462
commit 837298a171
7 files changed
+74 -21

No files matched your search

@@ -122,6 +122,7 @@ function WireguardDataGrid({
expandedContent: (row: WgIfaceWithServer) => (
<WireGuardPeersDetail
peers={row.peers}
interfaceName={row.name}
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
/>
@@ -1,5 +1,6 @@
"use client"
import { useState } from "react"
import type { WireGuardPeer } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -30,13 +31,20 @@ function peerKey(peer: WireGuardPeer, index: number): string {
function WireGuardPeersDetail({
peers,
interfaceName,
onDeletePeer,
onExportPeer,
}: {
peers: WireGuardPeer[]
interfaceName: string
onDeletePeer?: (peerId: string) => void
onExportPeer?: (peerId: string) => void
}) {
const [page, setPage] = useState(0)
const lastPage = Math.max(0, Math.ceil(peers.length / 50) - 1)
const currentPage = Math.min(page, lastPage)
const offset = currentPage * 50
if (page > lastPage) setPage(lastPage)
if (peers.length === 0) {
return (
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
@@ -46,30 +54,30 @@ function WireGuardPeersDetail({
}
return (
<div className="border-t border-border/50">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
<span className="sr-only">Действия</span>
<section aria-label={"Пиры: " + interfaceName} className="sticky left-0 w-full max-w-[calc(100vw-2rem)] border-t border-border/50 md:max-w-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 bg-muted/10">
<p className="text-xs text-muted-foreground" aria-live="polite">Пиры {offset + 1}–{Math.min(offset + 50, peers.length)} из {peers.length}</p>
{peers.length > 50 && <div className="flex gap-1">
<Button type="button" variant="outline" className="min-h-11" disabled={currentPage === 0} aria-label="Предыдущая страница пиров" onClick={() => setPage(currentPage - 1)}>Назад</Button>
<Button type="button" variant="outline" className="min-h-11" disabled={currentPage >= lastPage} aria-label="Следующая страница пиров" onClick={() => setPage(currentPage + 1)}>Далее</Button>
</div>}
</div>
{peers.map((peer, index) => {
const id = peerKey(peer, index)
{peers.slice(offset, offset + 50).map((peer, index) => {
const id = peerKey(peer, offset + index)
return (
<div
key={id}
className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
data-wireguard-peer={id}
className="grid grid-cols-2 gap-3 px-4 py-3 items-center text-xs border-t border-border/50 bg-muted/20 md:grid-cols-[1fr_1fr_auto_auto_auto_auto]"
>
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
<span className="font-mono text-muted-foreground break-all" title={peer.publicKey}>
{peer.name || truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
<div className="min-w-0 break-all font-mono text-muted-foreground">
<span className="sr-only">Allowed IPs: </span><span>{peer.allowedIps.join(", ")}</span>
</div>
<span
className={cn(
@@ -77,7 +85,7 @@ function WireGuardPeersDetail({
peer.latestHandshake ? "text-success" : "text-muted-foreground",
)}
>
{peer.latestHandshake ?? "нет рукопожатия"}
<span className="sr-only">Последнее рукопожатие: </span>{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span aria-label="RX" className="flex items-center gap-0.5">
@@ -89,14 +97,14 @@ function WireGuardPeersDetail({
{fmtBytes(peer.transferTx)}
</span>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
<span className="min-w-0 break-all font-mono text-muted-foreground/60 text-[11px]"><span className="sr-only">Endpoint: </span>{peer.endpoint ?? "—"}</span>
<div className="flex items-center gap-1 justify-end">
{onExportPeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
className="size-11"
aria-label="Экспорт peer .conf"
onClick={(e) => {
e.stopPropagation()
@@ -111,7 +119,7 @@ function WireGuardPeersDetail({
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
className="size-11 text-destructive"
aria-label="Удалить пира"
onClick={(e) => {
e.stopPropagation()
@@ -125,7 +133,7 @@ function WireGuardPeersDetail({
</div>
)
})}
</div>
</section>
)
}
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-04
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принLine truncated
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принLine truncated
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+8
View File
@@ -36,6 +36,14 @@
## 12.3.30 — порционное раскрытие пиров WireGuard (2026-10-04)
Раскрытие интерфейса ограничено50 пирами на страницу, сохраняет страницу при refresh и ограничивает её при сокращении списка до отображения. Имена и точные IDs действий сохранены. На320px карточки имеют две колонки внутри sticky области шириной экрана; кнопки44px, пагинация доступна с клавиатуры и показывает диапазон/общий счётчик. На ПК сохраняется компактная строка полей.
Профиль1000 пиров:50 карточек/2388 DOM nodes. Проверены Enter/страница20/refresh,320px при крайнем horizontal scroll/доступ к действиям/axe detail, правильный последний пир в delete dialog и1000→1.31 browser прошли; после устранения setState-in-effect повтор большого detail1/1. TypeScript без incremental, ESLint и production frontend build прошли. [Снимок320px](ux-ui/screens/large-wireguard-detail-320.png) просмотрен. Измерения используют локальные HTTP-моки, RouterOS latency не измерена.
Следующий блок12.3.31: убрать вывод «онлайн» из наличия старого handshake, показать отсутствие данных отдельно. Официальный RouterOS last-handshake — время после последнего успешного рукопожатия: https://help.mikrotik.com/docs/spaces/ROS/pages/69664792/WireGuard. Затем WireGuard URL filters/context и оставшиеся network grids.17 manualPagination:true grids/общая регрессия после delta/внешняя12.5 остаются. Коммит локальный без push.
## 12.3.29 — большой каталог пиров WireGuard (2026-10-04)
Peer grid теперь использует client pagination50, clamp и сохранение страницы при refresh. Полный поиск/сортировка выполняются до страницы, composite server/interface/peer ID сохранён. Штатные ReUI ScrollArea/start pinning удерживают идентификатор на320px, действия имеют44px цель. Подтверждение удаления теперь показывает имя и точный ID пира, а не только интерфейс.
+9
View File
@@ -416,3 +416,12 @@ Peer grid теперь использует client pagination50, clamp и сох
Осталось17 manualPagination:true specialized grids, включая bounded/demo источники. Следующий блок12.3.30: ограничить DOM раскрытия большого списка пиров одного интерфейса. Восстановление WireGuard search/tab/status из URL, аудит подписи «онлайн» по факту handshake и внешняя12.5 остаются; presence старого handshake не доказывает текущую доступность. Общая main/context после delta целиком ещё не повторена. Коммит локальный без push.
## 12.3.30 — порционное раскрытие пиров WireGuard (2026-10-04)
Раскрытие интерфейса ограничено50 пирами на страницу, сохраняет страницу при refresh и ограничивает её при сокращении списка до отображения. Имена и точные IDs действий сохранены. На320px карточки имеют две колонки внутри sticky области шириной экрана; кнопки44px, пагинация доступна с клавиатуры и показывает диапазон/общий счётчик. На ПК сохраняется компактная строка полей.
Профиль1000 пиров:50 карточек/2388 DOM nodes. Проверены Enter/страница20/refresh,320px при крайнем horizontal scroll/доступ к действиям/axe detail, правильный последний пир в delete dialog и1000→1.31 browser прошли; после устранения setState-in-effect повтор большого detail1/1. TypeScript без incremental, ESLint и production frontend build прошли. [Снимок320px](ux-ui/screens/large-wireguard-detail-320.png) просмотрен. Измерения используют локальные HTTP-моки, RouterOS latency не измерена.
Следующий блок12.3.31: убрать вывод «онлайн» из наличия старого handshake, показать отсутствие данных отдельно. Официальный RouterOS last-handshake — время после последнего успешного рукопожатия: https://help.mikrotik.com/docs/spaces/ROS/pages/69664792/WireGuard. Затем WireGuard URL filters/context и оставшиеся network grids.17 manualPagination:true grids/общая регрессия после delta/внешняя12.5 остаются. Коммит локальный без push.
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+27
View File
@@ -52,3 +52,30 @@ test("WireGuard1000 peers: full search/sort/page/cache/target/320px", async ({ p
await expect(page.getByRole("alertdialog")).toContainText("Пир 1000");await page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true}).click()
expect(writes).toEqual([])
})
test("WireGuard1000 peer details: bounded DOM/page/refresh/clamp/keyboard/320px", async ({ page }, info) => {
test.setTimeout(90000)
let peers = Array.from({length:1000},(_,i)=>({id:"p"+(i+1),rosId:"*P"+(i+1),publicKey:"key-"+(i+1),name:"Пир "+(i+1),allowedIps:["10."+Math.floor(i/256)+"."+(i%256)+".1/32"],transferRx:0}))
let calls=0
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;if(p==="/api/wireguard"){calls++;return r.fulfill({json:wgListResponseSchema.parse({interfaces:[{id:"*1",rosId:"*1",name:"wg-main",serverId:"1",serverName:"Роутер",listenPort:13231,mtu:1420,comment:"",enabled:true,status:"up",peers}]})})}return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[]:{}})})
await page.goto("/wireguard")
await page.getByRole("button",{name:"Пиры: wg-main",exact:true}).click()
const region=page.getByRole("region",{name:"Пиры: wg-main",exact:true}),items=region.locator("[data-wireguard-peer]")
await expect(items).toHaveCount(50)
const profile={peers:peers.length,rendered:await items.count(),nodes:await page.locator("*").count()};console.log("WG detail profile",JSON.stringify(profile));await info.attach("profile",{body:JSON.stringify(profile),contentType:"application/json"})
const next=region.getByRole("button",{name:"Следующая страница пиров",exact:true})
await next.focus();await page.keyboard.press("Enter");await expect(items.first()).toHaveAttribute("data-wireguard-peer","p51")
for(let i=1;i<19;i++)await next.click()
await expect(next).toBeDisabled();await expect(items.first()).toHaveAttribute("data-wireguard-peer","p951")
const before=calls;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>calls).toBeGreaterThan(before);await expect(items.first()).toHaveAttribute("data-wireguard-peer","p951")
await page.setViewportSize({width:320,height:1000});await region.scrollIntoViewIfNeeded()
const scroll=page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]');await scroll.evaluate(el=>{el.scrollLeft=el.scrollWidth})
expect(await region.evaluate(el=>{const r=el.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth})).toBe(true)
await expect(items.first().getByRole("button",{name:"Удалить пира",exact:true})).toBeInViewport()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await page.addScriptTag({content:axe.source});expect((await page.evaluate(async()=>(window as unknown as {axe:typeof axe}).axe.run(document.querySelector('section[aria-label="Пиры: wg-main"]')!,{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa"]}}))).violations).toEqual([])
await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:info.outputPath("wireguard-detail-320.png"),animations:"disabled"})
await items.last().getByRole("button",{name:"Удалить пира",exact:true}).click();await expect(page.getByRole("alertdialog")).toContainText("Пир 1000");await page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true}).click()
peers=[peers[999]];await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(items).toHaveCount(1);await expect(items.first()).toHaveAttribute("data-wireguard-peer","p1000")
})