fix(ui): ограничить группы и детали рекурсивных маршрутов

This commit is contained in:
Denozordec committed 2026-10-04 15:52:07 +07:00
1 parent f515818254
commit 9124d451a5
8 files changed
+127 -19

No files matched your search

@@ -1,8 +1,8 @@
"use client"
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
type ColumnDef,
useTable,
@@ -67,9 +67,14 @@ function inferCountry(name: string): string | null {
}
function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
const [page, setPage] = useState(0)
const sorted = useMemo(() => [...group.endpoints].sort((a, b) => a.distance - b.distance), [group.endpoints])
const lastPage = Math.max(0, Math.ceil(sorted.length / 50) - 1)
if (page > lastPage) setPage(lastPage)
const currentPage = Math.min(page, lastPage)
const offset = currentPage * 50
return (
<div className="flex flex-col gap-4 px-5 py-4 bg-muted/20">
<div role="region" aria-label={`Конечные точки: ${group.dstAddress} · ${group.routingTable}`} className="sticky left-0 w-full max-w-[calc(100vw-3rem)] md:max-w-none flex flex-col gap-4 px-5 py-4 bg-muted/20">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">
Route: <span className="font-mono text-foreground">{group.dstAddress}</span>
@@ -81,15 +86,15 @@ function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{sorted.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
{sorted.slice(offset, offset + 50).map((ep, idx) => (
<div key={ep.id} data-recursive-endpoint={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{ep.country && (
<Flag code={ep.country} size={16} />
)}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1}
Endpoint {offset + idx + 1}
</span>
</div>
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
@@ -104,6 +109,11 @@ function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
</div>
))}
</div>
{sorted.length > 50 && <div className="flex flex-wrap items-center gap-2">
<span role="status" className="text-xs text-muted-foreground">Конечные точки {offset + 1}–{Math.min(offset + 50, sorted.length)} из {sorted.length}</span>
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие конечные точки" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Назад</Button>
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие конечные точки" disabled={currentPage === lastPage} onClick={() => setPage(currentPage + 1)}>Далее</Button>
</div>}
</div>
)
}
@@ -139,6 +149,7 @@ function RecursiveRoutesDataGrid({
() => [
{
id: "route",
size: 176,
accessorKey: "dstAddress",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Route / Comment" className="ml-1" />
@@ -147,7 +158,7 @@ function RecursiveRoutesDataGrid({
const g = row.original
const isExpanded = expandedKey === g.key
return (
<button type="button" aria-label={`Раскрыть маршрут ${g.dstAddress} · ${g.routingTable}`} aria-expanded={isExpanded} onClick={event => { event.stopPropagation(); onExpandedChange?.(isExpanded ? null : g.key) }} className="flex items-start gap-2 min-w-0 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
<button type="button" aria-label={`Раскрыть маршрут ${g.dstAddress} · ${g.routingTable}`} aria-expanded={isExpanded} onClick={event => { event.stopPropagation(); onExpandedChange?.(isExpanded ? null : g.key) }} className="flex items-start gap-2 min-h-11 min-w-0 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
{isExpanded ? (
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
@@ -165,19 +176,20 @@ function RecursiveRoutesDataGrid({
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (group: RecursiveRouteGroup) => (
<RouteGroupExpandedDetail group={group} />
<RouteGroupExpandedDetail key={group.key} group={group} />
),
},
},
{
id: "gateways",
size: 208,
header: () => <span className="text-xs font-medium text-muted-foreground">Gateways</span>,
enableSorting: false,
cell: ({ row }) => {
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
return (
<div className="flex flex-col gap-0.5">
{sorted.map(ep => {
{sorted.slice(0, 3).map(ep => {
const code = ep.country
return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
@@ -200,6 +212,7 @@ function RecursiveRoutesDataGrid({
</div>
)
})}
{sorted.length > 3 && <span className="text-xs text-muted-foreground">Ещё {sorted.length - 3} · раскройте маршрут</span>}
</div>
)
},
@@ -207,6 +220,7 @@ function RecursiveRoutesDataGrid({
},
{
id: "epCount",
size: 64,
header: () => <span className="text-xs font-medium text-muted-foreground">EP</span>,
enableSorting: false,
cell: ({ row }) => (
@@ -216,6 +230,7 @@ function RecursiveRoutesDataGrid({
},
{
id: "priority",
size: 96,
header: () => <span className="text-xs font-medium text-muted-foreground">Priority</span>,
enableSorting: false,
cell: ({ row }) => {
@@ -226,6 +241,7 @@ function RecursiveRoutesDataGrid({
},
{
id: "table",
size: 128,
accessorKey: "routingTable",
header: ({ column }) => <DataGridSortHeader column={column} title="Table" />,
cell: ({ row }) => (
@@ -253,7 +269,7 @@ function RecursiveRoutesDataGrid({
>
<Button type="button" size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
className="size-11 p-0 text-muted-foreground hover:text-foreground"
aria-label={`Редактировать маршрут ${g.dstAddress} · ${g.routingTable}`}
disabled={mutationsDisabled}
onClick={() => onEdit(g)}
@@ -263,7 +279,7 @@ function RecursiveRoutesDataGrid({
<Button type="button" size="sm"
variant="ghost"
className={cn(
"size-7 p-0 transition-colors",
"size-11 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
@@ -289,7 +305,7 @@ function RecursiveRoutesDataGrid({
</div>
)
},
size: 80,
size: 112,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
@@ -298,7 +314,9 @@ function RecursiveRoutesDataGrid({
const table = useTable({
features: dataGridFeatures,
manualPagination: true,
manualPagination: false,
autoResetPageIndex: false,
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["route"], end: [] } },
data: groups,
columns,
getRowId: (row) => row.id ?? row.key,
@@ -306,6 +324,20 @@ function RecursiveRoutesDataGrid({
state: { expanded },
})
const requestedExpansion = useRef<string | null>(null)
const { pageIndex, pageSize } = table.state.pagination
useEffect(() => {
const lastPage = Math.max(0, Math.ceil(groups.length / pageSize) - 1)
if (pageIndex > lastPage) table.setPageIndex(lastPage)
if (!expandedKey) { requestedExpansion.current = null; return }
const index = table.getSortedRowModel().rows.findIndex(row => row.original.key === expandedKey)
if (index < 0) { requestedExpansion.current = null; return }
if (requestedExpansion.current !== expandedKey) {
requestedExpansion.current = expandedKey
table.setPageIndex(Math.floor(index / pageSize))
}
}, [groups.length, expandedKey, pageIndex, pageSize, table])
if (groups.length === 0) {
return (
<EmptyState
@@ -320,6 +352,8 @@ function RecursiveRoutesDataGrid({
<DataGridShell
table={table}
recordCount={groups.length}
pagination={groups.length > 50 || pageSize !== 50}
tableLayout={{ dense: true, columnsPinnable: true }}
onRowClick={(row) => {
onExpandedChange?.(expandedKey === row.key ? null : row.key)
}}
@@ -327,7 +361,11 @@ function RecursiveRoutesDataGrid({
headerRow: "border-b border-border",
bodyRow: cn("group/row cursor-pointer", expandedKey && "data-[state=selected]:bg-muted/30"),
}}
/>
>
<DataGridContainer border={false} className="w-full rounded-none border-0">
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
</DataGridContainer>
</DataGridShell>
)
}
+4 -3
View File
@@ -15,7 +15,7 @@
## Оставшиеся специализированные grids
Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —14 файлов после12.3.38. Это не15 дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API.
Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —13 файлов после12.3.40. Это не15 дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API.
| Файл | Источник и необходимая проверка |
| --- | --- |
@@ -26,7 +26,6 @@
| [gre-pools-data-grid.tsx](../components/data-grids/gre-pools-data-grid.tsx) | В live displayPools=[]; только mockGrePools в demo. Приоритета live1000 нет. |
| [ospf-neighbors-data-grid.tsx](../components/data-grids/ospf-neighbors-data-grid.tsx) | Полный /ospf/catalog; одновременно SVG topology с distinct router IDs. Нужен профиль всего экрана, не только таблицы. |
| [probes-schedule-data-grid.tsx](../components/data-grids/probes-schedule-data-grid.tsx) | Только demo ScheduleTab/INIT_RULES3; live использует ProbesSpeedSchedule. Demo список расширяется пользователем; не доказательство live API. |
| [recursive-routes-data-grid.tsx](../components/data-grids/recursive-routes-data-grid.tsx) | Полный live routes → groupedRoutes; проверить1000 групп и отдельно большую группу с endpoints/expanded details, edit/delete/export всего списка. |
| [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. |
@@ -34,7 +33,7 @@
| [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 и связывание с графиками. |
Очередь: clipboard Communities → recursive groups/details → Filters порядок → Firewall/DnD → OSPF таблица+topology → optimizer preview/комбинаторика. Scheduler11, traffic cap50 и commRecs24 проверять на реальной границе, не искусственно подставлять1000 в недоступный DTO. Demo и неиспользуемый Subusers отделены от live приёмки. Все новые профили должны сохранять search/sort/refresh/clamp, точный target и права,320px и URL scope.
Очередь: форма recursive routes/gateway choices → Filters порядок → Firewall/DnD → OSPF таблица+topology → optimizer preview/комбинаторика. 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).
@@ -79,3 +78,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
12.3.28–12.3.35: WireGuard interfaces1000→50/2136 nodes/1573ms, peers1000→50/2596 nodes/1369ms, раскрытые peers1000→50/2388 nodes; VXLAN1000→50/2137 nodes/1249ms; GRE1000→50/2748 nodes/1609ms. Baseline до по этим экранам не замерялся; сравнение latency между разными dev прогонами не является ускорением production. Evidence и проверки по каждому блоку в ux-ui-stage-12.md.
12.3.38: Communities1000/190573 JSON bytes →50 DOM rows/2216 page nodes/1206ms dev ready.19 browser/type/lint/build прошли.14 manualPagination:true остаются; обновления/selection по ID подтверждены локально.
12.3.40: recursive1000 групп→50 rows/2118 nodes/1457ms; одна группа1000 endpoints→50 cards/1126 nodes.35 browser/type/lint/build прошли; большой RouteSheet пока вне принятого performance scope.13 manualPagination:true остаются.
+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
@@ -31,6 +31,14 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, 12.3.26–12.3.33 приняты локально; 12.3.34 VXLAN и12.3.35 GRE tunnels приняты; остаются network grids/общая регрессия после delta/внешняя приёмка |
## 12.3.40 — большие группы рекурсивных маршрутов (2026-10-04)
ReUI pagination50 групп, global sort, refresh без сброса страницы и clamp. Первичное/новое URL раскрытие находит группу в sorted model и открывает её страницу; обычный переход страниц не навязывает возврат к выбранной группе. Строка показывает3 gateway и точное число остальных. Раскрытие имеет отдельные страницы50 endpoints, сортирует всю группу по distance, сохраняет страницу при refresh и корректирует её при сокращении. На320px route закреплён, edit/delete44px и доступны через горизонтальный scroll; детали ограничены viewport и доступны с клавиатуры.
35/35 browser (large/table/links/layout/values/data) прошли:1000 групп/212347 JSON bytes →50 rows/2118 nodes/1457ms dev ready;1000 endpoints →50 cards/1126 nodes. Проверены URL page20, full search/global Table sort, refresh/cache/permissions/1000→1, expanded endpoint page20/refresh/clamp и320px/axe. TypeScript без incremental/ESLint и production frontend build прошли. [Группы320px](ux-ui/screens/large-recursive-groups-320.png), [endpoints320px](ux-ui/screens/large-recursive-endpoints-320.png) просмотрены. Изменение/удаление получает всю исходную группу, профильные операции прошли на малых fixtures; большой write payload в этом блоке не проверялся.
Осталось13 manualPagination:true specialized grids. Большая RouteSheet пока рендерит все endpoints и все варианты gateways для каждого endpoint; performance формы не принят. Следующий блок12.3.41: ограничить DOM формы/вариантов шлюзов без потери скрытых данных/валидации/PUT payload, проверить1000 endpoints и1000 gateways. Затем Filters порядок/Firewall DnD и остальные этапы карты. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.39 — подтверждение копирования Communities (2026-10-04)
Clipboard success показывается только после resolved writeText; reject/недоступный API сохраняет значение и показывает ReUI Alert с повтором/ручным копированием. Pending блокирует повторный вызов и кнопки списка/деталей; активная операция и timer защищены mount/revision guard, источник страницы уже ключуется sourceKey. Уведомления pending/success доступны через role=status.
+9
View File
@@ -506,3 +506,12 @@ Clipboard success показывается только после resolved writ
Следующий блок12.3.40: recursive routes1000 групп и1000 endpoints одной группы, pagination/URL expansion/global sort/refresh/cache/320px.14 manualPagination:true остаются. Общая main/context регрессия и внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.40 — большие группы рекурсивных маршрутов (2026-10-04)
ReUI pagination50 групп, global sort, refresh без сброса страницы и clamp. Первичное/новое URL раскрытие находит группу в sorted model и открывает её страницу; обычный переход страниц не навязывает возврат к выбранной группе. Строка показывает3 gateway и точное число остальных. Раскрытие имеет отдельные страницы50 endpoints, сортирует всю группу по distance, сохраняет страницу при refresh и корректирует её при сокращении. На320px route закреплён, edit/delete44px и доступны через горизонтальный scroll; детали ограничены viewport и доступны с клавиатуры.
35/35 browser (large/table/links/layout/values/data) прошли:1000 групп/212347 JSON bytes →50 rows/2118 nodes/1457ms dev ready;1000 endpoints →50 cards/1126 nodes. Проверены URL page20, full search/global Table sort, refresh/cache/permissions/1000→1, expanded endpoint page20/refresh/clamp и320px/axe. TypeScript без incremental/ESLint и production frontend build прошли. [Группы320px](ux-ui/screens/large-recursive-groups-320.png), [endpoints320px](ux-ui/screens/large-recursive-endpoints-320.png) просмотрены. Изменение/удаление получает всю исходную группу, профильные операции прошли на малых fixtures; большой write payload в этом блоке не проверялся.
Осталось13 manualPagination:true specialized grids. Большая RouteSheet пока рендерит все endpoints и все варианты gateways для каждого endpoint; performance формы не принят. Следующий блок12.3.41: ограничить DOM формы/вариантов шлюзов без потери скрытых данных/валидации/PUT payload, проверить1000 endpoints и1000 gateways. Затем Filters порядок/Firewall DnD и остальные этапы карты. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+52
View File
@@ -0,0 +1,52 @@
import { test, expect, type Page } from "@playwright/test"
import axe from "axe-core"
import { recursiveReadSchema } from "../../lib/recursive-route-response"
const row = (i: number) => ({ id: "r" + i, dstAddress: "10." + Math.floor((i-1)/256) + "." + ((i-1)%256) + ".0/24", gateway: "192.0.2.1%gw-" + i, distance: 1, scope: 30, targetScope: 10, routingTable: "t" + String(i).padStart(4,"0"), checkGateway: "ping", comment: "route-" + String(i).padStart(4,"0"), disabled: false, country: "RU" })
async function setup(page: Page, control: { routes: ReturnType<typeof row>[]; failed: boolean; calls: number }) {
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/recursive-routes/catalog") { control.calls++; return r.fulfill(control.failed ? { status: 503, json: { error: "Чтение маршрутов недоступно тест" } } : { json: recursiveReadSchema.parse({ serverId: 1, routes: control.routes, live: true, stale: false }) }) }
return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", country: "RU", enabled: true, status: null }] : p === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] } : {} })
})
}
test("Recursive1000 groups: URL page/search/sort/refresh/cache/320px", async ({ page }, info) => {
test.setTimeout(90000)
const control = { routes: Array.from({length:1000},(_,i)=>row(i+1)), failed: false, calls: 0 }
await setup(page, control); const last = control.routes[999]
const started = Date.now(); await page.goto("/recursive-routes?server=1&route=" + encodeURIComponent(last.dstAddress) + "&table=t1000&keep=x")
const rows = page.locator("tr[data-row-id]"); await expect(rows).toHaveCount(50)
await expect(page.getByRole("button", { name: "Раскрыть маршрут " + last.dstAddress + " · t1000", exact: true })).toHaveAttribute("aria-expanded", "true")
await expect(page.getByText("Endpoint 1", { exact: true })).toBeVisible()
const profile = { count:1000, jsonBytes:Buffer.byteLength(JSON.stringify(control.routes)), rows:await rows.count(), nodes:await page.locator("*").count(), readyMs:Date.now()-started }; console.log("Recursive groups profile",JSON.stringify(profile)); await info.attach("profile",{body:JSON.stringify(profile),contentType:"application/json"})
const next = page.getByRole("button", { name:"Следующая страница",exact:true }); await expect(next).toBeDisabled()
const refresh = page.getByRole("button", { name:"Обновить",exact:true }); const before=control.calls; await refresh.click(); await expect.poll(()=>control.calls).toBeGreaterThan(before); await expect(next).toBeDisabled()
await page.getByRole("button",{name:"Раскрыть маршрут " + last.dstAddress + " · t1000",exact:true}).click()
const search = page.getByRole("textbox",{name:"Поиск маршрутов",exact:true}); await search.fill("route-1000"); await expect(rows).toHaveCount(1); await expect(rows.first()).toContainText(last.dstAddress)
await search.fill(""); await expect(rows).toHaveCount(50); await page.getByRole("table").getByRole("button",{name:"Table",exact:true}).click(); await page.getByRole("table").getByRole("button",{name:"Table",exact:true}).click(); await expect(rows.first()).toContainText("t1000")
await next.click(); const id=await rows.first().getAttribute("data-row-id"); control.failed=true; await refresh.click(); await expect(page.getByRole("alert").filter({hasText:"Чтение маршрутов недоступно тест"})).toBeVisible(); await expect(rows.first()).toHaveAttribute("data-row-id",id!)
const edit=rows.first().getByRole("button",{name:/Редактировать маршрут/}); await expect(edit).toBeDisabled()
await page.setViewportSize({width:320,height:1000}); await edit.scrollIntoViewIfNeeded(); const viewport=page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]'); await viewport.evaluate(el=>{el.scrollLeft=el.scrollWidth}); await expect(edit).toBeInViewport()
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(320)
await page.addScriptTag({content:axe.source}); expect((await page.evaluate(async()=>(window as unknown as {axe:typeof axe}).axe.run(document.querySelector('[data-slot="data-grid"]')!,{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("recursive-groups-320.png"),animations:"disabled"})
control.failed=false; control.routes=[last]; await refresh.click(); await expect(rows).toHaveCount(1); await expect(rows.first()).toContainText(last.dstAddress)
await expect(page).toHaveURL(/keep=x/)
})
test("Recursive1000 endpoints: bounded preview/detail/last page/refresh/clamp/320px", async ({page},info)=>{
test.setTimeout(90000)
const control={routes:Array.from({length:1000},(_,i)=>({...row(i+1),dstAddress:"203.0.113.0/24",routingTable:"main",distance:Math.floor((999-i)/4)})),failed:false,calls:0}
await setup(page,control); await page.goto("/recursive-routes?server=1&route=203.0.113.0%2F24&table=main")
const region=page.getByRole("region",{name:"Конечные точки: 203.0.113.0/24 · main",exact:true}); const cards=region.locator('[data-recursive-endpoint]'); await expect(cards).toHaveCount(50)
await expect(page.getByText("Ещё 997 · раскройте маршрут",{exact:true})).toBeVisible()
await expect(cards.first()).toContainText("gw-997"); await expect(cards.first()).toContainText("distance: 0")
const profile={count:1000,cards:await cards.count(),nodes:await page.locator("*").count()};console.log("Recursive endpoints profile",JSON.stringify(profile));await info.attach("profile",{body:JSON.stringify(profile),contentType:"application/json"})
const next=region.getByRole("button",{name:"Следующие конечные точки",exact:true});for(let i=0;i<19;i++)await next.click()
await expect(next).toBeDisabled();await expect(cards.last()).toContainText("Endpoint 1000");await expect(cards.last()).toContainText("gw-4")
await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(cards.last()).toContainText("Endpoint 1000")
await page.setViewportSize({width:320,height:1000}); await page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]').evaluate(el=>{el.scrollLeft=0}); await cards.first().scrollIntoViewIfNeeded()
await expect.poll(()=>region.evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true)
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(320)
await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:info.outputPath("recursive-endpoints-320.png"),animations:"disabled"})
control.routes=[control.routes[999]];await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(cards).toHaveCount(1);await expect(cards.first()).toContainText("Endpoint 1");await expect(cards.first()).toContainText("gw-1000")
})