fix(ui): ограничить таблицу Firewall и сохранить порядок правил

This commit is contained in:
Denozordec committed 2026-10-04 16:23:29 +07:00
1 parent 4a5ae8baa1
commit 1e9889f892
7 files changed
+164 -22

No files matched your search

@@ -2,7 +2,7 @@
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { useMemo } from "react"
import { useEffect, useMemo } from "react"
import {
type ColumnDef,
useTable,
@@ -26,11 +26,13 @@ import {
DATA_GRID_CONTAINER_CLASS,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
import { DataGridContainer, DataGridScrollArea, DataGridTable, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
import type { DragEndEvent } from "@dnd-kit/core"
import { EmptyState } from "@/components/empty-state"
import {
CopyIcon,
ChevronUpIcon,
ChevronDownIcon,
MoreHorizontalIcon,
PencilIcon,
PowerIcon,
@@ -111,6 +113,17 @@ function FirewallRulesDataGrid({
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
[rules],
)
const neighbours = useMemo(() => {
const groups = new Map<string, FirewallRule[]>()
for (const rule of rules) {
const key = [rule.serverId, rule.family, rule.table].join(":")
const group = groups.get(key) ?? []
group.push(rule); groups.set(key, group)
}
const result = new Map<string, { up?: string; down?: string }>()
for (const group of groups.values()) group.forEach((rule, index) => result.set(rule.id, { up: group[index - 1]?.id, down: group[index + 1]?.id }))
return result
}, [rules])
const reorderable = Boolean(onReorder)
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
@@ -121,8 +134,15 @@ function FirewallRulesDataGrid({
id: "drag",
header: () => <span className="sr-only">Порядок</span>,
enableSorting: false,
cell: () => <DataGridTableDndRowHandle />,
size: 40,
cell: ({ row }) => {
const targets = neighbours.get(row.original.id)
return <div className="flex items-center gap-1">
<DataGridTableDndRowHandle className="size-11 shrink-0" />
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={`Поднять правило ${row.original.rosId || row.original.id}`} disabled={!targets?.up || mutationsDisabled} onClick={() => targets?.up && onReorder?.(row.original.id, targets.up)}><ChevronUpIcon /></Button>
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={`Опустить правило ${row.original.rosId || row.original.id}`} disabled={!targets?.down || mutationsDisabled} onClick={() => targets?.down && onReorder?.(row.original.id, targets.down)}><ChevronDownIcon /></Button>
</div>
},
size: 160,
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
@@ -163,6 +183,7 @@ function FirewallRulesDataGrid({
: []),
{
id: "chain",
size: 112,
accessorKey: "chain",
header: ({ column }) => <DataGridSortHeader column={column} title="Цепочка" />,
cell: ({ row }) => <ChainBadge chain={row.original.chain} />,
@@ -170,6 +191,7 @@ function FirewallRulesDataGrid({
},
{
id: "action",
size: 176,
accessorKey: "action",
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
cell: ({ row }) => <ActionBadge action={row.original.action} />,
@@ -177,6 +199,7 @@ function FirewallRulesDataGrid({
},
{
id: "src",
size: 176,
accessorKey: "src",
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" />,
cell: ({ row }) => (
@@ -188,6 +211,7 @@ function FirewallRulesDataGrid({
},
{
id: "dst",
size: 176,
accessorKey: "dst",
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
cell: ({ row }) => (
@@ -199,6 +223,7 @@ function FirewallRulesDataGrid({
},
{
id: "proto",
size: 96,
accessorKey: "proto",
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
cell: ({ row }) => <span className="text-xs font-mono">{row.original.proto}</span>,
@@ -206,6 +231,7 @@ function FirewallRulesDataGrid({
},
{
id: "port",
size: 112,
accessorKey: "port",
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
cell: ({ row }) => (
@@ -215,6 +241,7 @@ function FirewallRulesDataGrid({
},
{
id: "iface",
size: 160,
accessorKey: "iface",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
@@ -224,6 +251,7 @@ function FirewallRulesDataGrid({
},
{
id: "hits",
size: 112,
accessorKey: "hits",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пакетов" className="ml-auto" />
@@ -253,12 +281,13 @@ function FirewallRulesDataGrid({
},
{
id: "enabled",
size: 64,
accessorKey: "enabled",
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
enableSorting: false,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<FormToggle disabled={mutationsDisabled} checked={row.original.enabled} onChange={() => onToggle(row.original.id)} />
<FormToggle ariaLabel={`Включено правило ${row.original.rosId || row.original.id}`} disabled={mutationsDisabled} checked={row.original.enabled} onChange={() => onToggle(row.original.id)} />
</div>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
@@ -279,8 +308,8 @@ function FirewallRulesDataGrid({
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"transition-colors",
"data-popup-open:opacity-100",
)}
aria-label={`Действия: ${r.chain} ${r.action}`}
@@ -313,16 +342,18 @@ function FirewallRulesDataGrid({
</div>
)
},
size: 56,
size: 64,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onEdit, onToggle, onDelete, showServer, reorderable, indexPad, mutationsDisabled],
[onEdit, onToggle, onDelete, onReorder, neighbours, showServer, reorderable, indexPad, mutationsDisabled],
)
const table = useTable({
features: dataGridFeatures,
manualPagination: true,
manualPagination: false,
autoResetPageIndex: false,
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index", "chain"], end: [] } },
data: indexedRules,
columns,
manualSorting: reorderable,
@@ -330,6 +361,12 @@ function FirewallRulesDataGrid({
getRowId: (row) => row.id,
})
const { pageIndex, pageSize } = table.state.pagination
useEffect(() => {
const lastPage = Math.max(0, Math.ceil(rules.length / pageSize) - 1)
if (pageIndex > lastPage) table.setPageIndex(lastPage)
}, [rules.length, pageIndex, pageSize, table])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id || !onReorder) return
@@ -351,19 +388,18 @@ function FirewallRulesDataGrid({
<DataGridShell
table={table}
recordCount={rules.length}
pagination={rules.length > 50 || pageSize !== 50}
tableLayout={{ dense: true, columnsPinnable: true }}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
}}
>
{reorderable ? (
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
<DataGridTableDndRows
dataIds={indexedRules.map((r) => r.id)}
handleDragEnd={handleDragEnd}
/>
</DataGridContainer>
) : undefined}
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
<DataGridScrollArea>
{reorderable ? <DataGridTableDndRows dataIds={table.getRowModel().rows.map(row => row.id)} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</DataGridScrollArea>
</DataGridContainer>
</DataGridShell>
)
}
+4 -3
View File
@@ -15,12 +15,11 @@
## Оставшиеся специализированные grids
Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —12 файлов после12.3.42. Это не12 доказанных дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API.
Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —11 файлов после12.3.43. Это не11 доказанных дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API.
| Файл | Источник и необходимая проверка |
| --- | --- |
| [data-collection-scheduler-data-grid.tsx](../components/data-grids/data-collection-scheduler-data-grid.tsx) | 11 фиксированных SCHEDULER_JOB_KEYS (lib/scheduler-settings.ts); UI map этих ключей. Fixture1000 не соответствует источнику. |
| [firewall-rules-data-grid.tsx](../components/data-grids/firewall-rules-data-grid.tsx) | filteredRules; reorderable отключает сортировку, DnD dataIds включает исходный массив. Ограничение DOM требует отдельной проверки cross-page порядка. |
| [firewall-scenario-rules-data-grid.tsx](../components/data-grids/firewall-scenario-rules-data-grid.tsx) | Локальный редактор сценария, динамический массив; не путать с live правилами. Проверить сохранение/симуляцию всего сценария. |
| [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. Нужен профиль всего экрана, не только таблицы. |
@@ -32,7 +31,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 и связывание с графиками. |
Очередь: Firewall/DnD → OSPF таблица+topology → optimizer preview/комбинаторика. Scheduler11, traffic cap50 и commRecs24 проверять на реальной границе, не искусственно подставлять1000 в недоступный DTO. Demo и неиспользуемый Subusers отделены от live приёмки. Все новые профили должны сохранять search/sort/refresh/clamp, точный target и права,320px и URL scope.
Очередь: Firewall stale guard/редактор сценария → 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).
@@ -83,3 +82,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
12.3.41: recursive RouteSheet1000 endpoints ×1000 gateways →10 cards/1569 modal nodes, максимум5 gateway choices на карточку; полный PUT1001 сохранён.22+1 browser/type/lint/build. Расчёт/валидация полного draft остаётся на клиенте.
12.3.42: Filters1000/120894 JSON bytes →50 rows/2266 nodes/1880ms dev ready. Global order/граница страниц/полный PUT/403/поиск/сортировка/refresh/cache/clamp/320px/axe проверены;10+1 browser/type/lint/build прошли.12 manualPagination:true остаются.
12.3.43: Firewall1000/253248 JSON bytes →50 rows/3054 nodes/2082ms dev ready. Cross-page кнопки/точный RouterOS destination/DnD page2/403/search/cache/clamp/320px/axe проверены;35+1 main/context4/type/lint/build прошли. Stale operations после полного GET failure требуют12.3.44; большой multi-server sort отдельно не проверен.11 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.43 — большая таблица Firewall и порядок (2026-10-04)
ReUI pagination50/clamp/ScrollArea, закреплённые номер и chain. DnD получает ID текущей страницы, кнопки44px up/down используют соседей полного отфильтрованного списка внутри server/family/table. Callback прежний: RouterOS destination рассчитывается по полному источнику, включая скрытые строки. DnD мышью/клавиатурой сохранён; action menu44px видим без hover. Переключателям добавлены доступные имена с rosId.
13+22 профильных main browser, финальный large1/1 и context4/4 прошли.1000/253248 JSON bytes →50 rows/3054 nodes/2082ms dev ready. Down r50→destination *52, keyboard up r50→*51, DnD page2 r51→*53,403 rollback/refresh page/search1000/cache503/1000→1,320px/action44px/axe проверены. TypeScript без incremental/финальный ESLint и production build прошли. [Firewall320px](ux-ui/screens/large-firewall-320.png) просмотрен. Полный DTO остаётся в памяти; real RouterOS не проверен. Global sorting большого multi-server среза отдельно не проверена, reorderable сохраняет manualSorting.
Обнаружено прежнее ограничение: dataError после полного GET failure сохраняет кэш, но не блокирует действия, в отличие от partial failures. Следующий блок12.3.44: явная блокировка stale операций/повторного чтения/восстановления; затем большой редактор сценария и OSPF/optimizer.11 manualPagination:true specialized grids остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.42 — большой список Filters (2026-10-04)
ReUI pagination50/clamp/закреплённые номер и community; доступные действия44px без hover. Индекс и крайние позиции берутся из полного orderedRules через Map. Поиск сортирует весь срез, очистка поиска возвращает исходный порядок (manualSorting), перемещение отправляет все правила.
+9
View File
@@ -533,3 +533,12 @@ ReUI pagination50/clamp/закреплённые номер и community; дос
Осталось12 manualPagination:true specialized grids. Следующий блок12.3.43: Firewall1000/pagination/порядок DnD и действия, точный RouterOS destination через границу страниц. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.43 — большая таблица Firewall и порядок (2026-10-04)
ReUI pagination50/clamp/ScrollArea, закреплённые номер и chain. DnD получает ID текущей страницы, кнопки44px up/down используют соседей полного отфильтрованного списка внутри server/family/table. Callback прежний: RouterOS destination рассчитывается по полному источнику, включая скрытые строки. DnD мышью/клавиатурой сохранён; action menu44px видим без hover. Переключателям добавлены доступные имена с rosId.
13+22 профильных main browser, финальный large1/1 и context4/4 прошли.1000/253248 JSON bytes →50 rows/3054 nodes/2082ms dev ready. Down r50→destination *52, keyboard up r50→*51, DnD page2 r51→*53,403 rollback/refresh page/search1000/cache503/1000→1,320px/action44px/axe проверены. TypeScript без incremental/финальный ESLint и production build прошли. [Firewall320px](ux-ui/screens/large-firewall-320.png) просмотрен. Полный DTO остаётся в памяти; real RouterOS не проверен. Global sorting большого multi-server среза отдельно не проверена, reorderable сохраняет manualSorting.
Обнаружено прежнее ограничение: dataError после полного GET failure сохраняет кэш, но не блокирует действия, в отличие от partial failures. Следующий блок12.3.44: явная блокировка stale операций/повторного чтения/восстановления; затем большой редактор сценария и OSPF/optimizer.11 manualPagination:true specialized grids остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+88
View File
@@ -0,0 +1,88 @@
import { test, expect } from "@playwright/test"
import type { FirewallRule } from "../../lib/data"
import axe from "axe-core"
test("Firewall1000: страницы, точный destination, DnD, ошибки и320px", async ({ page }, info) => {
test.setTimeout(90000)
let rules: FirewallRule[] = Array.from({ length: 1000 }, (_, i) => ({
id: `r${i + 1}`, rosId: `*${i + 1}`, serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: `192.0.2.${i % 250 + 1}`, dst: "", port: "443", iface: "", comment: `rule-${String(i + 1).padStart(4, "0")}`, enabled: true, hits: i + 1,
}))
const moves: Record<string, unknown>[] = []
let failed = false, writeFail = false, reads = 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/firewall/rules/move") {
const body = r.request().postDataJSON(); moves.push(body)
if (writeFail) return r.fulfill({ status: 403, json: { error: "Перемещение запрещено1000" } })
const active = rules.find(rule => rule.rosId === body.rosId)!
rules = rules.filter(rule => rule !== active)
const destination = rules.findIndex(rule => rule.rosId === body.destinationRosId)
rules.splice(destination < 0 ? rules.length : destination, 0, active)
return r.fulfill({ json: { ok: true } })
}
if (p === "/api/firewall/all") {
reads++
return r.fulfill(failed ? { status: 503, json: { error: "Firewall недоступен1000" } } : { json: { rules, addressLists: [] } })
}
return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
const start = Date.now()
await page.goto("/firewall?server=1")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(50)
const profile = { count: 1000, bytes: Buffer.byteLength(JSON.stringify(rules)), rows: await rows.count(), nodes: await page.locator("*").count(), readyMs: Date.now() - start }
console.log("Firewall profile", JSON.stringify(profile))
await info.attach("profile", { body: JSON.stringify(profile), contentType: "application/json" })
await expect(rows.first().getByRole("button", { name: "Поднять правило *1", exact: true })).toBeDisabled()
await rows.last().getByRole("button", { name: "Опустить правило *50", exact: true }).click()
await expect.poll(() => moves.length).toBe(1)
expect(moves[0]).toEqual({ serverId: "1", family: "ip", table: "filter", rosId: "*50", destinationRosId: "*52" })
await expect(rows.last()).toHaveAttribute("data-row-id", "r51")
const next = page.getByRole("button", { name: "Следующая страница", exact: true })
await next.click(); await expect(rows.first()).toHaveAttribute("data-row-id", "r50")
await rows.first().getByRole("button", { name: "Поднять правило *50", exact: true }).focus()
await page.keyboard.press("Enter")
await expect.poll(() => moves.length).toBe(2)
expect(moves[1]).toEqual({ serverId: "1", family: "ip", table: "filter", rosId: "*50", destinationRosId: "*51" })
await expect(rows.first()).toHaveAttribute("data-row-id", "r51")
const handle = rows.first().getByRole("button", { name: "Перетащите для изменения порядка строк", exact: true })
await handle.focus(); await page.keyboard.press("Space"); await expect(handle).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("ArrowDown"); await expect(page.getByRole("status").filter({ hasText: "over droppable area r52" })).toBeVisible(); await page.keyboard.press("Space")
await expect.poll(() => moves.length).toBe(3)
expect(moves[2]).toEqual({ serverId: "1", family: "ip", table: "filter", rosId: "*51", destinationRosId: "*53" })
await expect(rows.first()).toHaveAttribute("data-row-id", "r52")
writeFail = true
await rows.first().getByRole("button", { name: "Поднять правило *52", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Перемещение запрещено1000" })).toBeVisible()
await expect(rows.first()).toHaveAttribute("data-row-id", "r52"); writeFail = false
const read = reads
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => reads).toBeGreaterThan(read); await expect(rows.first()).toHaveAttribute("data-row-id", "r52")
const search = page.getByPlaceholder("Поиск по адресу, действию…")
await search.fill("rule-1000"); await expect(rows).toHaveCount(1)
await expect(rows.first()).toHaveAttribute("data-row-id", "r1000")
await expect(rows.first().getByRole("button", { name: "Опустить правило *1000", exact: true })).toHaveCount(0)
await search.fill(""); await expect(rows).toHaveCount(50)
await next.click(); await expect(rows.first()).toHaveAttribute("data-row-id", "r52")
failed = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Firewall недоступен1000" })).toBeVisible()
await expect(rows.first()).toHaveAttribute("data-row-id", "r52")
await expect(page.getByText("Показаны последние успешно загруженные данные.", { exact: false })).toBeVisible()
await page.setViewportSize({ width: 320, height: 1000 })
const action = rows.first().getByRole("button", { name: "Действия: forward accept", exact: true })
await action.scrollIntoViewIfNeeded()
await page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]').evaluate(el => { el.scrollLeft = el.scrollWidth })
await expect(action).toBeInViewport()
expect((await action.boundingBox())?.height).toBeGreaterThanOrEqual(44)
expect(await 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("firewall-320.png"), animations: "disabled" })
failed = false; rules = [rules[999]]
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "r1000")
expect(moves).toHaveLength(4)
})