From 1e9889f892923a8d5643f0e6566a441ab01e2139 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 4 Oct 2026 16:23:29 +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=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D1=83=20Firewall=20=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data-grids/firewall-rules-data-grid.tsx | 72 ++++++++++---- docs/ux-ui-performance.md | 7 +- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 8 ++ docs/ux-ui-stage-12.md | 9 ++ docs/ux-ui/screens/large-firewall-320.png | Bin 0 -> 46694 bytes tests/e2e/large-firewall.spec.ts | 88 ++++++++++++++++++ 7 files changed, 164 insertions(+), 22 deletions(-) create mode 100644 docs/ux-ui/screens/large-firewall-320.png create mode 100644 tests/e2e/large-firewall.spec.ts diff --git a/components/data-grids/firewall-rules-data-grid.tsx b/components/data-grids/firewall-rules-data-grid.tsx index 498e7d4d..a820b0eb 100644 --- a/components/data-grids/firewall-rules-data-grid.tsx +++ b/components/data-grids/firewall-rules-data-grid.tsx @@ -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() + 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() + 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: () => Порядок, enableSorting: false, - cell: () => , - size: 40, + cell: ({ row }) => { + const targets = neighbours.get(row.original.id) + return
+ + + +
+ }, + 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 }) => , cell: ({ row }) => , @@ -170,6 +191,7 @@ function FirewallRulesDataGrid({ }, { id: "action", + size: 176, accessorKey: "action", header: ({ column }) => , cell: ({ row }) => , @@ -177,6 +199,7 @@ function FirewallRulesDataGrid({ }, { id: "src", + size: 176, accessorKey: "src", header: ({ column }) => , cell: ({ row }) => ( @@ -188,6 +211,7 @@ function FirewallRulesDataGrid({ }, { id: "dst", + size: 176, accessorKey: "dst", header: ({ column }) => , cell: ({ row }) => ( @@ -199,6 +223,7 @@ function FirewallRulesDataGrid({ }, { id: "proto", + size: 96, accessorKey: "proto", header: ({ column }) => , cell: ({ row }) => {row.original.proto}, @@ -206,6 +231,7 @@ function FirewallRulesDataGrid({ }, { id: "port", + size: 112, accessorKey: "port", header: ({ column }) => , cell: ({ row }) => ( @@ -215,6 +241,7 @@ function FirewallRulesDataGrid({ }, { id: "iface", + size: 160, accessorKey: "iface", header: ({ column }) => , cell: ({ row }) => ( @@ -224,6 +251,7 @@ function FirewallRulesDataGrid({ }, { id: "hits", + size: 112, accessorKey: "hits", header: ({ column }) => ( @@ -253,12 +281,13 @@ function FirewallRulesDataGrid({ }, { id: "enabled", + size: 64, accessorKey: "enabled", header: () => Вкл, enableSorting: false, cell: ({ row }) => (
e.stopPropagation()}> - onToggle(row.original.id)} /> + onToggle(row.original.id)} />
), 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({ ) }, - 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({ 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 ? ( - - r.id)} - handleDragEnd={handleDragEnd} - /> - - ) : undefined} + + + {reorderable ? row.id)} handleDragEnd={handleDragEnd} /> : } + + ) } diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index 3f5268fd..71952b16 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -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 остаются. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index d2745fe5..8349e019 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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 принят локально:BFD1000/50 DOM/cache/320px/12 browser/build. 12.4.4 принят локально:context213/25 файлов и unit36 прошли на5b2a291. 12.4.5 принят локально:main1110/171 прошли на21859be. 12.3.26 принят локально:strict atomic Ping write/подтверждение каталога и сохранения/история удаления/62 browser/context11/DI6/builds. 12.3.27 принят локально:WireGuard zero/missing counters/UI/backend mapper/29 browser/unit/builds. 12.3.28 принят локально:WireGuard interfaces1000/50 DOM/pinning/actions/cache/29+1 browser/build. 12.3.29 принят локально:WG peers1000/50 DOM/pinning/точный delete target/29+3 browser/build. 12.3.30 принят локально:WG detail1000/50 DOM/320px/клавиатура/31+1 browser/build. 12.3.31 принят локально:WG handshake без ложного online/unknown/7+1 browser/build. 12.3.32 принят локально:WG URL search/tab/status/reload/история/34+4 browser/unit/build. 12.3.33 принят локально:WG strict read/cache/freshness/unknown/40+7 browser/context8/build. 12.3.34 принят локально:VXLAN1000/50 DOM/pinning/export/22 browser/build. 12.3.35 принят локально:GRE1000/50 DOM/pinning/20+1 browser/build. 12.3.36 карта15 оставшихся источников обновлена. 12.3.37 top50 traffic/пустой локальный фильтр/перенос tabs принят в описанных границах. 12.3.38 Communities1000/50 DOM/19 browser/build принят локально. 12.3.39 clipboard ack/error/retry/unmount/5 browser/build принят локально. 12.3.40 recursive groups/endpoints1000/50 DOM/35 browser/build принят локально. 12.3.41 большая форма1000×1000/10 cards/полный PUT/22+1 browser/build принята локально. 12.3.42 Filters1000/50 DOM/полный PUT/10+1 browser/build принят локально. Следующий блок12.3.43: Firewall pagination/DnD, затем полная main/context регрессия.12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. +Этап 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 принят локально:BFD1000/50 DOM/cache/320px/12 browser/build. 12.4.4 принят локально:context213/25 файлов и unit36 прошли на5b2a291. 12.4.5 принят локально:main1110/171 прошли на21859be. 12.3.26 принят локально:strict atomic Ping write/подтверждение каталога и сохранения/история удаления/62 browser/context11/DI6/builds. 12.3.27 принят локально:WireGuard zero/missing counters/UI/backend mapper/29 browser/unit/builds. 12.3.28 принят локально:WireGuard interfaces1000/50 DOM/pinning/actions/cache/29+1 browser/build. 12.3.29 принят локально:WG peers1000/50 DOM/pinning/точный delete target/29+3 browser/build. 12.3.30 принят локально:WG detail1000/50 DOM/320px/клавиатура/31+1 browser/build. 12.3.31 принят локально:WG handshake без ложного online/unknown/7+1 browser/build. 12.3.32 принят локально:WG URL search/tab/status/reload/история/34+4 browser/unit/build. 12.3.33 принят локально:WG strict read/cache/freshness/unknown/40+7 browser/context8/build. 12.3.34 принят локально:VXLAN1000/50 DOM/pinning/export/22 browser/build. 12.3.35 принят локально:GRE1000/50 DOM/pinning/20+1 browser/build. 12.3.36 карта15 оставшихся источников обновлена. 12.3.37 top50 traffic/пустой локальный фильтр/перенос tabs принят в описанных границах. 12.3.38 Communities1000/50 DOM/19 browser/build принят локально. 12.3.39 clipboard ack/error/retry/unmount/5 browser/build принят локально. 12.3.40 recursive groups/endpoints1000/50 DOM/35 browser/build принят локально. 12.3.41 большая форма1000×1000/10 cards/полный PUT/22+1 browser/build принята локально. 12.3.42 Filters1000/50 DOM/полный PUT/10+1 browser/build принят локально. 12.3.43 Firewall1000/50 DOM/DnD/35+1 main/context4/build принят локально. Следующий блок12.3.44: stale Firewall guard; затем сценарий/OSPF/optimizer и полная main/context регрессия.12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 1492ac1f..7ec7fa2d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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), перемещение отправляет все правила. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index a38de27b..c979e327 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -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. + diff --git a/docs/ux-ui/screens/large-firewall-320.png b/docs/ux-ui/screens/large-firewall-320.png new file mode 100644 index 0000000000000000000000000000000000000000..220d32be877413569bdceb08afe5ddee384aa5f5 GIT binary patch literal 46694 zcmagFbyOTtwC+i8clV&d9fH$9aCg_>u8l)*mk`{8ySqEV-QA_3ahKuVHFw^dd*7`2 zXRp;&tE*0Ro!;l$zw_+~B?U8<5$xfI;jlB5mV975#WzIyl5h70rU(-G645k-H?ngjCr?kZap{)P((i1w zg=Jr9XlQ1ZAqFF1aBKr0XU~*l&tA7SdLs}$fW*V+PeNZ?`1$!cfFR&j22VQ&GOnZu zI&>ZlXCOJe8Q=>ge8_(bwR~T^J-jfX0mx8Htbt1S*#BPv)(Vb6vtvD9(nCy4OiBtK zwAtdcmyLXKa#FtJBNP2M?-x0|34W38b`nE!=hbt$bt54(qpNEG*`064`)WBuG7CqU z)*qHY{MZxR?*alo4-VbJt=m@1n@xARkCkiosFv;C5B3FnPayFbtgpaAHIf&@ZM8bu zv+fIj&w&mRzGM_h1{&OM9P9@eZIe7g4DqO}e_*H}OIe5Y<@XD?NfZ=fetuV9i~&ax zCU()$bCCU!)bqobV4_Z5yex1aaU;^nfLk=s3Y&jn8}6 zL)JNSZ$c6YqK?x*n@+-;xMS-$5k$>Ea{CS-0^+&Z0D8+=KhP0_ASM3yU|JF21c3nH z!``C`#l;7FLq`0cL9X4rkQ0%`;)o6kRsx7}zSd|1@bvWa(WXlk2U|y%!@=pKH2@pMeOu!qsfTAuT~ zu?GzxwGT#d*G`w_jYiiBd-zq1Obl<{6taWosk;Mjv z?=pNk-2`sBU?B?Y)m$Fap0zzaVL{i@@GXYGT6OX}ybDIan3+8sL1UqGxe~n@E6pUE zjlHd}ySDGPwf)Gc1YaBplUJ;PNlBLzxG9ytekBuVy-dmfxAlI*li2Iwd$^ZqVq&ys zZoFfo+pIS+79x;nYLYBScw`G!QrCU(_x^aLyQ;Yu03J>}I5{PZ9<;~ORKDD4aw%}H z_qtXD=-y<4CGmE>j4U2y>?g~}`~pytWZddoj`Oqlx5LQI`xbhy&S|spmn->4)KjkK zTdoq}kRMM*cE$r=58@_0yeBhZa{Mq4&)Q#ShV60iI-k#*S>&?rLHqxB5bclt{CNdO zcr@yC?%$xqp%Ad!9AAR^`nFV5;AazD8(`eo1UPPk5j+C|4rFjFwYrhA&vP$HjUtV` z=b~6Pm>KSzol8qo7$05jK5AIABED6ZzIS)u2W}gtvE9^f`G4$O_O?<=FvUf^JcGNp z{c0`y2A=XIyKfgXdkr=f6*IS~YBYID;taZ8PY)+B(;Ci)j%~td1iAeBkW`E8mv8vB znDRs}?O$I3J3H}5^NltRb{jf6OrduAq#LbPD=Uf>@9^A1KOVfTo;ej05>*QMT;5i7 z*O~z4=~){tadK(AuJ{A+Jp(!=>e#VfUb?EwZkUms`4#j=O^-RG*75 zes`Zb%Ua5u>Zvqc6-dJ$(&|Z@MyFjdSrHLT-0$vIX*uO zo<2hZNk~_aVAf|`ZOI<9HyzeIn%f!9PWE3%4;^Sa-^^S5du%w!6vsx>@4G(?#wY#! zuCYT@w3#Z=(578YUP5}a0&fu!1GfYrAtUJ-st>jfot6#Xo{sjLxA=Z1a~DWXozw<} zO9x~M={&0aOzBx_vi+6gDEzTR>aPbJ*;-)g?g04<`x#Ou9II?&et_3o4|Y#!h4rC; ziJO34u+S6LpUd$A>6_u`&iE{@kHm%UBC4ELFS|oHFjSK3(Vq#*L zz|36}B^Ho54ler%GG5;F)JA1p6LWI|G^PVCjx-LUX5RGH1WLN=`MsS~ur92n7F7+c z6r5?RlaZhSGgF3{n2B=2uU~i{4p>%oOb^7J9vm+P_q#+GE3&03{_SA0mc#oTL8Kz6 zXhmhuAGu+0w}paPne5Ik+rWT!IM?Ovbf^s2G`A390e?S74>Z)ZBqhdbGS zQd=oru7@2S7Rqj{_3}rS|2L~tKq0^J0uy+>S=R!bAEWhW_&`=NW2hgmU z8dyj>1cssO3AO`5YRRlBWo&1lV|MoJshPKsaayq?4h{d`F}jjz|IAp*?MhEc->_Qg z{Ikx*6So(%E8Bj3O)4X9R$f~**_f#dVeSU77|BC#S+guUTNA?yjf7$Q_4@k7%+p^> zb~qc0b|h%};g^}SXPTDJ)C`|h{v6Tv&R6V9f3G{Yx37d**$Igp+_u+Ux=lIQpAc0_#}mnEso9{I294DuXpRTuwL%M$n+7{ zZ7ewn{~$j^?MLCMpYksWHmEv`21KS4qDL1eMnwhf{bL}od%QHl{1EhZ-)XtZ+Z2Lo z1BP|=7wR^*d3ZFXKD|2xRp~=-zYjITcNQjjgFqn5hr)iLpxCK|66=-=7)iW z=);KS$jCqeL6NoXq5i7G&Q5U}hN_B^WU1GU4PM6NNuXT#Okjtp4wyhq!~NCJ-MU}!WDDQX}z?fvych8w#! zxQD!@kGx1nPZ=REWjB(()}%``uBQn%b_aDx-ESWIhjY*c@{JwYSr%t0nD#cV3X=;cDZJ zb{bw?T@TL37S;{9?_l(BuJnT@ighp=dmYf>@+V+?V8kJRaA*sv<*m5d04eZt^YMyx zAJxrl{~&-e-{!vWYVRd1l!=(1v}<9Yq2bT1Yi+^Bl`&;*By}Q9ErHx62K?;jas?B* zv(ntP<{me-ZbF7%m*&I>+)A4>P*6~eC1^iCNHQY$&0<)VB(vijoDe4j=sl{h4{z#U zr=_A|;>W7mGfiO;JM#4SNd;{lS7?~bAeZw@$hhF||9BDfgk+G(9b+!ZP+t0ed6cu2 zDF~-$^Zj`vQqy%sWQ~m30}*oO%zq0d;j(f}VKD{6%-qJ}Dn`z{ zV4^QM%N(F%zuC63-az)%)iGjo7eG7?2wBn^+up7V~=*l`&rLfGZQQBs<-zNng66Q7Q3E*bWZ1wZq z@x`Fi-F-=_?sM2^QMRF@VP)cW|FLdrgXz9w8Doc@+q5>7OLX_G&}pXL}5JsP8B1b4aKVm%XU*5143gjD6!lOk6Fv;QSOhFKE9rL)u_;&9yj1 zips0`M}#S1_5*kAm2%y#kZe|fEhH9W%>i^Yxzt)Jxe;-r*#EB3{+nR?KYcXK3^;M+ z6Z#QBP7;i;%dXHvpCkh)CVFn-4LJE9gXbPJAS0Uu864$y44;W{fk%e>|1^i~Zw zt{ZCm5*bFO7W>or5gzUJ%R<(TL885Cr8TAJB*2_9 z_LKb_MC$(0nwJO2Q<9>(!;~g@rGcNlN_`}U`=)Vx{0$qy#@ZUk*|SsadHO7c72g#$ zrq`&Jm>9&sKtH&!5b0jQ@%a!&kpX~CW@ctv?((`il~Og#eS3W5P^E8w#`et)4oZv* z#MpMsV0S_L0|!pBGBQMjmrS9!O63b~ZkUpi-h4642~zNZuJ{sgwYggi&X~W>f8?~H zY>$;zR0Oghb}a7pT; z4-Mc$-V04ID#4=AQdd_G&DlLWyIGF@ejW<`msos#eXSCk6R@%T3yR!qcv64BUAgSf zr5gtH32QV~jZ9(Sl|et{)R>_`o5|Cdr)RS{_W_PNmO||s&B{X^BKYQ)4;qXqerF}pWU4_SEa_GhEY_PhCp!b?kY!@ld+Ojfh;{~X>L zxHY`zf&XG%EHF3IIXcQs0jJ44ITCjiXuKpQzw)+_vTtrBEsisf4qxwWAc+_39^lcs zAI7z#BtmLkKJ+k}Pvgyb?1pU;!wpwgmGklBY3bTi?0oFLy&Si5d{hrC05V%&dd$vw z!6f58nN-j*UN}wpizF*|B_NA#CkIS+XZ1es`j;0z_V5aUn`$ma#+7VITzlc0Rq}_S z(SlZ5U`6`N1wa(n2WXT#Hcy)gn}3K!M)}j-)p#D+PP#+R(LQqy1jng9IHkZ ze}FS~3!KXFi6y&kXHy((yC^_>Zddh`c-1;D7rh6;6D~&lj+|!WtM4Ew9`4e^coMSC z^ivV3f!Bu=XbZo)!^Nm{RNge9oCzA@K=MUE7`#VMKq_2lif*(h%cU!IftpXVX-8qr zt=M^JP86ln{s?CF*~=?9cI^JO&1ApcLGjJ{*CBd_uv;6=ynN3lrhK^F)vcdBmG2tp zh)3ke6KFn?MzTlS1!|YEsXvqT+sYDoecU^WzD-)>uD0G!Hlrjt`zgM+YABSN`|n)n z%(rG?5TP5qc$kX%!5{2%_as3vCRqOgW{NR@2y{gynS~Q_+TE|x?{3I~6V=YW@x}y{ z=b|;Yv|~1wEpAKEif2CW-E;@hu=qZ&4La}QvP%I+9IhqMabD$+r>TQIzGFJ68<-w5 zNHYe_3DO=%5_FupJMElvkPY%}KKqU}@#FAT`P%$hJ7D_QPiS&FWpjFcj8Mh%f85CC zFr1)(pY?PN59qCGIu_=E^XmJ&zpARUOt|h68)XLd1mVg=!u|#Xlt-tYkHdS>RRs~h z&$=L&u&~)Frp$m_aKDqS0CBEU+#n904V_S%Ok%ym`qGkqv0uY`NKu{W;Q$QYN2~b) z)bNP?+aVtDKAr%moO74QXB9J$JWC=B{&D}x)M(dLxU^VtGsk#wd^o%)(dTs&3Lyj4 zAn8ed^K!|%9u}_0@)F6A*02s@3o7ar=eusj~uMl!|jlX8g}w=k2XYuQ#hVVIxq+-xcDZ;(T@B zU6ajbP5yHhE&;9xvb6tkLggX}1n+_1S!PR)a%q zxDaHn3eQpXrh4SdSKnz022#1x|pWmOaFCYPR z@cWm!lUrteNEun#H9um)@rw6C7{^p&EqusHPaPES`kkz7cD>!~Gz8@R`1ttetYO2q z`}OwrW^H3#$W8|!h0bBwcIM^fZT(yTVDs~*F{K6ZEYet!aic=!R4GUzXCSM}%T3t{ zAONe-T@<8oYu7}Y@Fp~$iwCA--VX|8DCXdKzRQCiO*L)hh?^5~l1RFG6?$w$?u>H~ zh$=~@kwf?^@JrLu;Gi~5qNbY0?n994w<36aW|=fObII`h=>&=VIS>dty_9xD4D~p9 z@FySu!rpFS*IJ210Wrpvlcs7sSFF7Ok<81hr2)~ZsR8lUd&WhAQLOzxq|Z^{SQN$o z;E4Z;6?$SrTN-TDIGFSn-ij!zK={MTShAxY!VU;`_yavU>{Z)niVm677E-lBQBA zRi`oZho9N}a>D8L!;G4Z6blA95{Hj3GE;nEfeW#Qs}(0<)I?J=o|<|@ zd3m`RJAvn800Wv|zSmmZzpVngDbXM=eLcMv0efxjG&P-SH9=Q2(eedreK-k zNx3{z(cvGRQ6Bq3J#%M3+u4>C3>LSnn4gz%sj8?LScBD#v{8telUj@F+K=6S`TC2 zp8WSPp4Nu9eiW|Y!7iv`OI~qm`DQq$hV3BX-_Fj)-N~np$>8>tt%!#G8n^5M9|b76 z0QEZN>q%P|xT_zM)vzk`IEDoT$bYJiwUzX0f#b6?j+#0CxZpx(_aQeQ&m@ zCKU~$ix8V}X21@6$kiqspF*4!PKNVqi=PQ_d17L0ZW7P;CA*Q(7Ai!=*beRMiEVXH zuXK)zuKrhSM7Onv0v?|E!-JU?f?vOW*=YFNKGJa)ZXO1N3KE}R(}zDDmssTGm1lZA z?^mR86btiFwizP7_Jp<VOW~Zn+3ftX<)>GW*b~4BmZRQ*b$$S)>5wE=AIO40yz~)MYr)fYJ$C666?V z0#_fTwEHcIrLg}WB#8IPD33JIW}CnLBv zODOl=4(DM`Ut6Vx9i*iuPVV<5r(o+#?{{$rG9#M&%M~#+HP1A3f?^I&p2y8;bEAe_ zs>&*+J!fD;>U5%b6%kc6f6fvI&gaKXqaFXg#=imDI4csD`OyPqcv?*y0(iG5a3p!PNNNtg6RLCG+?fRSBob? zLwFpoLDm8bU!Sk~5LcUYfif*lGt57v`=AKDMnUe_f$i(845EdB}NfY_g zUbmlR=D=THpAKCn3ZsMa#GpYsF!OhG=L$Bf|0SCOGf%$7lS|Y# zlRC9dfbR)hmHDh}$T}H7+r^A@%eCEMVTySuiD>e}ia4DwxtqHt6M_4_f1ksG*H1Eg zYWfbwP6m9!#ruSO{{~ag*7R)o)R)%PAr=rV7e*=cJm-g@kpzgrCa+A>_AtMFSlTOo zGle{cv)^z7@vTQ^5@kvwjUI1Gas`Vqm~cYRT4|rS1NxJq0zs1&34U+b{VeP`!kaa_ z(q_$k&q;g>HN6E#z-Yv&(5hG1I0zNsSXI#^vwBfBq4@)tls_Wqc}5up?>&wgO%nxU znmkpGmjlq*Y&y65dxbkHpCue}RC|PH=Teu84`Fv1n%eXw%k#AD|gj_lzNR>${HVA7)yf?rX*J z)$fgBu~I40qNk3%(0AisC=@>7wm(o`z(5_&J1H2<8Ip`Zd-Z9f>v~t$$&Q9r6sUVE zpW^Z3THaV}H#>y8MF_>4FUPWTch@j7LZ3mUtJHd;Cl0aeUy${ADS?u)dim-8PuuJ3DRin_Ubnq=G&wV2Qt-|-ZVn2{Z z(KJm7G7rovD$6_ZUA;6mVs^2gLXbpCu?xn4=Nugg**WCVv2K3tLO|O}s<5&+_ zB*6ZZePwW&*?ZqN)LLImOhTMamd?Rr@DLrQ4Po}4ekpr{eGK~$5gCe7(>cx1b#xp= zfK@?5aecu-FAC>YLPqK8s2nkH;gwC2FylAYz&|%CmPOp8Px{l%ZoLqr-MLbjv9u|X zj}y9Q#ISNh`#i5ZwwV)IOPb%X^cV{OM6+*k8m$U}W80=`;&b;9W3*kGx$b3e$B0dN zzS<$gOBdsNCisI|+rzq#^c<|bbr?ae0}tqz!n8d`{Z*hVu))SYTD?D=BCC3#VBmXM z7fr%zH`Yf=Wpr9rylXK$_}qL;Gd@E$b%?nIsP5zv47k;MoCvg(TsaVn5vl?Ad{m{M zN_sKMr!FLZZFlOJ>1%K0EwVRwU#M&!Jt17{SI+z_ zci_0wi_bWK#C)bmhj=Y+^%L2SNOM%*OT0Z-Nj3&w_h}Dlw>TyjOT0s^=WMLjCTE%o zo68I+tS{AmznUX(xt&(6x`_GAqb9lI(MJrZR<Tn{8}VBh$|^ zTXpkfo3AHb^KLAav73#~yWTI6Ta@B-T@6nY)Y42@S-0yp8A09AhOY|<6UViT;OQ_H zuGZC%!ptY4DNDE{+25P`vz))F+n?7YZ7!~Ra%Ep_v8CT`4b&-VRMoW+4)OkKZ!0t> zDOPm=iNDjUoa;#G>dQ3vQ}8d!x8v-fM4`a_q_A2ucUO@W2=)?bu$-Scj0w`Xve)2tOwq)E*(qW2*e;v*r_5`%Pg;s=dvwLt*73sDH;k+wjSUtJE$;7MivF9RC*S9jAFH9As}kH3E~w1+1IQj_ zJTa784uw|ja_Pp!wD7(&7%_@O$0Cts|495(0evS_oj4pqt#NIJ1v*~wSAImjCET(t zVRh+?q>HmMx{wm4(&f-g{_5qt~m_^ z;8)euha}j1Lvu?@OCwhN&<@F>vs=*-diQOSaM`1S0}^3BJ-l9utlrsPAz5iS6T7+R zq@{Qpbnp92M!+Gajk7KsKz9~5*r1jHDv`@dk z`!#Ykst|N3d*JzW1@!OiA0A@b{1r2#mg1L+6Pw!^^XgTt}~3l%!t1FufJj6ZrpBXro~^ z)|NC-Z5VQ-acE7=MMWBnxD4268=$gnaBnk&``%9G{AR~@KE9p@gbOA%Og)`JaYrj0 zl9mRUmy_T^b#!qwW_xqOtnL*vRhofWFgS0k_z$Pva2 znbe=3!>Ft8o}OC!Fk-MFs($KfHzz0KdhMj7q{saj?EslSM2LKGD1z|^47P}m6Zzn(P7lMr#~DzpIRG7^@!q{{!;3m2qp*ra+8ZC{Mk-c|zr^G5OMW7zDj?sWpG#2ZC z4tXGTKOG%E^0~O`_mWurNGHh9F{B}HdDO(BY@w&zADwQGsM!K;UhDHGiKcHO{UM^I zJ;f&I{k7`_8mm8od_N(qC4YwDD^K^kLr0EZ9jDXb1zL_)9m$f4NIdRwk3}A!>N0<8 zfqByL<^8rDUh8Q=ckj)5%3hmqa%0Y7e%7vZbo=Dv#atZ%fWGhdNf*eK&z(2NZs{04 z6Q}8s!gA`nU2b@jE27a?J+#!{6{OC5bTURFbLj=rIzQOGIvTb$A#TCs&>D0vXubEw z6+JiV&;_Q3p*rQcox(UgeU>#Rs@o5f`rXHVXvE*qIR(yVTD%vfNA>x}_3Ql-%#C}8 zel#YfweBX&e4T@r-nm?{?fY_9wc2j%o-(_^BKWo{+*OX+#nl=0czn;}Pk(X+`l~Iw zc+PmirkYrPn?s%RT1aO+&R{5i*kko`@!oA}sIL0`eI}z6R2r2N<834Rl&4(&ow#dl zn@uq8{qiu3dS=WZwaw`KUN*r-$ct;?SA6&rm<3_-^>m^VFQS68F+_3nkhi9PSaDR~ zeo6QwWp~q?i8-~vR;w<*0Q`!Bw7d0w*#~wR=}da3YGdG|}A>WLC;u)?E$J;uwd=4vpt(~SM-m~OU@{RWdLroF3Rr_1iL=BtJk z#VPf$E48hx8hEL@Yt7=y=XRhYjxmK~YEib4oGlakvxlkYBy5ZisgIWp7V5)>{F zxHDQ6OnUEfJ*`oW;{N>KVjbeKhGT)~XI72%Bblw;b_b+=vp2fmArayDeX4)&8F;$w z?DCGK7k+LooJu)Yzb~pi?-+7OPib#idY1+3Rl>T}Kh?Gc%|vazoZdVni#M*YoOtJKQUx>&3FtE-L5udZZ&ovaQ6qQN&p? zk}k$QNx?QVKgC^s--V%sFH{d8?v3;*dFqaM^rfJAvmzh)Rr8bN0aMAYR$${!i)Poe z&s>{@9*dp9o0)Y)bj=FtgWB_WptdR;jF313J_U)qWDL|97qZAmS?fo~eWKz{F%HY~ zSxcck0(dItef9pN+6rrw#lh2WRI)k!h_~T3B87L$ACiz}Do*$MzA4`ACdJ3i1c5l= z;B(rp^NB7tE*B~0ywogJElG3(Ym~4?af|J|?GV=S$OR;1WO$KWslUm_`aQ1mT|>Nz z28snD3?x?b$d2njUnJWT;SM+mV0A0cC68}4R|NX&R$v3hgJ!@`VZqRN*x2~lhIk2~ z0gF&%JC>-fq4|O?=51e&Mu>q@LPPnNG#lRCxB^{l}dI?wJi;Ihu6&{2@{D2u2j&InAup~=n z;BSvUzf`Pl+0RGBqGpsWBMP#|`pvTV5kGmX>M41sX+yx->1nDQtO&XblpwZHPFQ`` z=g~lz~<1$COl_}as#rjv`S(WDmN zKK9d@S)`BTvo}kB4ws?Z~AX|*K#BRf0$>gEuYjGL3S2B|COC&3>XGxz%m z$_VMT?w7opns{;Ffks+c~4iKZnjv>)}a5_-l02(J^H4@=Y{hk2nc2 zw2rv|7XSGXjKRUdlsOLg>l%|t7}zOZ-YfD|0~-j?WxR3zbc&xP+hDM0H{OB`bq^Wj zJ2AAU7Tb>=^hS#Cg#iE9FalL%x`9jt6Rz!?J>wzlFB#jyhON#pe!#QE81h5db`CTS zTtrHT1rw2>S1{Sx%`xx^i1~Z#chAkC{_`LP4_pG>5x(wN6WKG z2eNPeX%v3Fol8bh^dx@y=BF=nmNw?jlG82Zzdj`VvGH<;_1ocya9vy)yM5b^eh zOZW}C@U6xjvARNpU#wnF&)sQH@LC^0Z-+HK!r7Ud4}y zdzyilh@>08ocA{{f#PfNi+&?NJeL^;g^T6;#_j9W%gwWB-NTxL@I81}AMU1j694JF zN(ys&Fa)rlPmAmaj%l#jX9L=q#|Aa=q6YPAwL71$OhlHhJahvQJ36K3eDld)Xll`J z78j4FZ>97YaH%HkA?Hrs-s`Rh~5dF9Se&{wH z?cUW9rb~G5-K9MUZCO+^yDaxjVCmhSaM7I!bv!OVH{0M=S;{4hnNcxLKU9_JycB#h z`cDaHq_0_RP`S0RvUx~1w0n98)8*C}jD9|9?Mz*=Fs2P;^f<|B&xRNro2vG9-AOcr z{&;d7OUvrilS8uw2%j!!l0Dt$tCJN9TzAogZHCZP)bQpg!UUxkuON6oEnH0uroRsDD>6$|_-w6R zDn^n6O-I+Hc8%OZlJI)AaK6m_7y*A6i({uydN zCWemmJr2h7Y9E}>ct_R(P#ypVPmF*SfFqxwsq^}o_ux^;H9AoA02Jehkq_s`7 zG<)@=dj=>~3k?p%+Rq#KL$1M%Y(Sv__0CH=tb;M}Uo5~}4&yRmfGSW6a%uBbO-)S=pEitNlD9&^B(OBnBTs!Y1(Fe5D=()nf8-m;sQYG?B z)#wAs!%G`(e1&{M-~oIw_iMt$aQ`*Hxin_ZM>NDjl}e2=b!!uQOV?vP)>LE({vc?0 zlL^=40HOl+R-HmjcxYShj9#PVl@*Mend#|VHF`64Z96-r7=@!6Dg| zTVA8S>hZ&>_F_iJX|9U2uOJlc1WLIMKHFXG%nmd0d904M+s*W(e(Aw^WFy<-#g|^? z{-L`=!}p8&?rZo3^r3M*;`DYGpu+yGBxtH?E3{r&7YY}LwWS8#CA0*m_PdgmoxVJj zF#(04-+boa9;B4hYM$_<9>d-<8X*_62}m`LPG~KGhMI;Po&=AlwwWiDC8^=`KH@E6 zUX?}3{rH9KO!A&=R@L2o4XyZ+1oy0G7Ql$lcKU~)AWtO-uDow320r(5>)v4{qgpWk zbjsljWZ+X_Ay?tD`ZWUhQ*o=UVqT{^p*Bxc#f|I0Q77Tu({VW9m)Oh8Wq6r(+f5B` z-2LHC($#xO>#o}inrT5wNfdaOpBO9Ws?x&_>ZL227@fY~fjpAwDelULb1bQb9zslt z?zCCuHvj&OJMR)>VlmE=m9VrWCsW#&OA~L6NKLhk&pFcPRyGLT%*WInnnC(}jF4Wm z*)sR8!V-U&79R%aYJK>D)>3A#m4~wAX)XU%as2I-P$R@XoTB(KjJD5%rF)ns=#S%R zX%OUoiows9&YLToQ^-h9TcuAE5()>_ez4*Dc~9~JTDQ!rU@R6nz~r?8Ox1LAU&xgM z<1Th6I^DmGS~r zhv$R)EEMd6TFNw;w2`C*WH`TNnh0$^#4~jE96Jh&6lnmc8fM~<@{d!$IjZjmu*QD3 zTlzSk2zEV6h{}$LIPF)Ey>fG+l6#-d+g)o$Dub@-ecQuDboXhZ9pf*GE2cP_UJ}ln zU&r&B^f^e#o&mf(__S-(FB;8fE)65FfTkmr#BPH!^j(aPB<$SnL4judfnHvTue%!Q zRNjtO!6aOE@shG%6~Jt!{Z32}VLE$Yg@3F_fvL4!b{7q-D#(D zJD!uF;}T;d<@%wmreu1_uWV7Xnad z%AHv5V75U@3I=xwxl%FFZlOuyf@U*cud#gBJZT(i#5e<%=N zD7)@XYo8nFPh_3xBN#AiQg+gB8DDXjbgsx)*{M67=>O2zXw#`$Q?b}&rtW1X-K454 zJIcSp>I>#9RMYcoUkq?R$Wd%Hkc8e$=nR`Up?GH7xhTb67D@mIfg8CdpqscvK5XK89()6JKw<=Q-u1dgs=utOj%hatq|d_ zvkN$83RZo$#sjMB1Zm?;qHe!TNA)=ul=L>+MW~^nLNiUptVL|+GOzbD)3<&Bb}(NB zFfU?k!~!=i3p=BW+Qo8AepcDTeOTfAZNwYoa+9B)w2#lRm{R~=O;H?LSBIkY*f)CF z%_mYh>r?7lgzSoOB|E+CM%`3t9UNl8C%Rz9j$99t9FGHo|BgyakK2#;A&~LI#73<5 z)1n}mf8(ew;Mqjvg_L|u*%Nd;BuAUbju#v2aLi98WtYn^JQlWeMT7h8PpvMKDo1F1 z9emFz3PjCe7a}egba%Dd%fp}S?ICWLa0i76E*sMnFVHl; zJw;3_JDzWY(;nO?zQ^{BVs(>MQ-Wus`;{$2{~;u{bd? zQNn9RL`e7vYdbqSQ2r4!W{MzF)}K0v)!|_8-#keOLJw_6LCc9^yQ#Z9Cm4WhrzYcY z!QLOkH@10{4^?6im4)IY#Gk!%1-oVKp@G_vUvr!s{(4&FONZlZUtAcd%pS|9u~t?j z>1>k^*J8a2bFtnSqLeXS`I-3raZ6IKBvl(Lqc?-4K8@E<-}t~!>1Zm;cUzE1L_akx zgoFjyOCeEeNWH(aY=OfwU;aY(Xw%$dX^K&RG7b6J#JB_<9Z}yFhVXlmO8{!fNWLGCU9~mU> zTY84bVewaESg0-U7Po6~x&jVG<&*&QxxCN-+0(h5Fs)c4plL9d^&J1i0z+4pwkQzk z%60mjFLcz+*sgzSdGX z{_;{ejAy&F|KNRJ4COF%dI9CPm*XEnY*4-7=loKu4z&TFuS#fg^pIOV*g9b{w!ni*L$c|+&1~W4{?l>J{H~A^IM}#o zIO5Zob3vl-E|udU+SH${Mv=V0^-r_Dj>rlsX8uWDI|H+~$)Am*r8cB)Jm>voU(*8N z+A$E){?~~Ds(gmYWVk6W_~~?z!?*XEg<-b2tM##Q%PpC^sl5H&NctiLi09hc5*&Qy z)f&4Qu+jw>~JsDcay|FH-C6?O-|mX$%#8KMzL#7}xRWq&W6EhIGff zy7>(IeZS2vCwB2Xn18w1KuNUJ&(Cdln=h*&Bj5+ZnRzHDx0Ji-QIwORigOa&fL?BZ zF8RR5&SY%e^U#i9JJvmD)B9pl@6+(L0`?a?&7K<=Z8m}!culG8NL!=P)DCWTi6G1!)$U=Nuu_=k7)%DPGGt;I}s+jZSv~MI@ ze?z<>w)#z-hQs7KFB!ec+v4Y(Q4*uek-xd^DD_BxY$1$X{@foK>coFk$uRUGWhi6q zf>p!!$#LsC?FWzJ$1`mPl^Onw-~XK~aFK6l$VA%QXG*x8*w2y%(vI7%GOjnfj*JFD z0~jwv=PX^*DzJ(y17-58XYJiJ)wZ(YojbKYoYr5nJ70{@fNw5@yRYs-;VRCQ^`#V3 zJF-hLpADs+UpVliS66jRiFG(27665?)sc^rzxc%}47ad6y||ViCSz4@&WbtTpwCc! zhXuA6tHJ&+XJX@ZL`O}D?#jOAo2#ZcZFcS>;~)Hygc6L5M#qn{R_PxDyYwc2Tt2%- z*M6@}Z4&}reuy!1IAp^%<4D7~U)i$$UhFbtiIeROnm39U&2%a0a+K{`e}gT2GuZtF z`0MCg^q+gV3Od(Uv3XA3%n9G+4Dqg~O=djvq)k~%0rZLW+H#VT#*&itxqM31a#h5Z zk=PrPzYUq+coy?2PdR{I$l9fAw%khVw{5e>3z9rsF;xF#P^;2}@Fahi$nhVAG&LYq zs>hRzNLMKZJoeVBf5SF^D9ID~hKm?RpZk~Qj?nA5_lLvBpOuRINpt?^M{Z3PI_an5 zr^eQa8^g}Tob&xPxlVB(uQ?m}Q!XetjY;jq+3y|W?nI5E!W1zA(OQ1^2(Hjxji&Wl zGD$irETxs@%H`wTtY19F(Hl+bw2rK(@(fRms* zD`-#n+6JVJ;LT%h%MT7=_lXaORo3iFWv7{$xBOR5ozk}KOpk9nj=I*!;aRLEJtYQy z#1s>qHQ;Kdun7kJfqTnhqz!Gz#o4P~%T6DR0lvKJRVnupCTJ_XM@G*7*^xdMq=o*g z?vio>-o(2>Rs6Hz9G1zojlkdv5FdsWuUL$bYH3(kI5&eJiH>Dlt3^&Ri@HiJ93t9l zvI81|H=$s}#S#}^{pAGNb@UG@M5TAf5&pA0?fqHzALS`Nt)c z@aGh#A}5YV3zuq^&7Tp`8<2{2&r3oKG3c3oOO`G~MQstm3&f8RA%}S4j5DT-0T##L z)u;eW*Lh`W5=B{v(?vf^UHtfHoehZVnc{$oKclgDHF{M>Y!{E?)8Ssyf{DH2l7s+c z=z%`-dcb#bGGB^}e~?ZXlzDRmjQI*mrE1%V6c+!*0+fI^k?Pje;?0jfF&Oq1a>M{z z(qwaXI%R$REIH=SW>QoVqvPL;i;I|)Y&t!wqabKVp>?KozJ7PUxhZ3p&pwxqQq@@U z5kbdRJ;!W7r;1glK_@y9F1$y)B?6LMg9N^hIbYBwqh-+yYpNORN-wznaSDXe@2Q?-+1R`fdEISDP>i;5OGGc0cZ@OgO% zWOoKZxekEmEP{%(xREM2Aru<3vPdR7e+k$g8{k4{(?b-BGAEuD%m|O))Yzgyt|NIBa`!X{v+R({_7c1KLLOi4Yu@i4M6 zV{b*6N+2H*gKt^1D`Ep*gqLnnN?XYU)IY@=bUTHg)cAW)d_B8^Jdt2(b(exTUQTe3BO?nDLnVG5N$~U zJq!I@qGz8c25%^$=QLrwGftrge83{DO8ify_Wvll|NrcjBX@uZZA4Cv&97g-tgYz< zVPQh3%M#@=BVe%$+xJR{vfrlazuX>i1O&PxQs~157r;;SR4Y6WBt^N4 z7?ke5qr&9)>DhKjgeEF=j-BH{A7PR|q!w>^b|lTW36Ja=_J z_2pgpJm2)ks5aYgI`|xPvdrIHHk=7S=Z~zMwqB0dyh*P<_ibhn!BcNuBHs~l&U=g- z=2h&@c_h?@t=QStJl)*HjUA+=X#|%za@*Es^JKiv&qZ{99G;Di{!z6@a6Rp(_A3NN zRBt@hY!ojtZUQ{`LPR(Xir zS(EzpMa*fEMc=bV!;`8Bxm~x{TkC5;bSJ^=&-jaXPbXE@X6Iev0mBSh+yha{+0Wez z9^=Q-L%bg|q~A$L8v=a=n{7HiPv3|rYY5QMIjzst z31cixO;b`_-j+7HUtV8%86gs-X`xfb2(a-ye7HmUfxI=>%OI?!Djk5X?v^{qW8B^l zVOLXkLsCD}j6Qst&NVBWHuihUhFL84{nkjEODC$RulPZ@rx4c0h^z4 z90m~Te?iaCJ$!bN1U}ziEvxyrjcyttB6M1K%r8G$gS%R|`OzcU)(oGE>EcwdoGaFC zQ=f-BZ?;|vNCBKGD?Z-MhaVTK0zcn7wJeEkI8ERAm>{u^8SQAO%8$o@NwqWgpTiONJ?>NHH;cznaYp&MO_ zbivH~#|IoNoXyd}Lv*l<<#75)O)wl$)g;l-;aP z$K53uf^i^hO9|B*E?4M5@Hp!n@&oaQncSY|l3}-9&Klx9hs9fo8g`c>SSen~6>_iy zoQHAQ>0=y@f607|!}eN}JlsFCZ!eMcf8R7}YaF+vbYt@l_#7|0)Jb%Y*u}WXxc}}SHKwMadwVG%x{?dA zMEADY>T0Be6At};wME+KP#lwr>+U|>_%<*Yb-5!(!gRhj zzH9r#dh`DJx-><6`^^tsLaXa16zC~NcxEm~t;vJfwvd{ts{cjQoVQ4&Qkqu;IOO`v zykA@%sE9<^K;;bV+&iJhr&VM@!~P;&k=FhFQGP$#=9ahWDK3}25WL}E=;Ffn&{Dx42%9`w|~ha@7r)mUH7e^e{`kI+Pz%V}s>O1z%WCCl11ayZcK}P7&cJ z2P~#G(S};;KbxAob(kS(`rZAPLujur!(8sglRs&!!x^TPFzg9@ z&vi6D8!kM9)fsDt$-}T8psmMxZA(tV;!bYE@@HV@t*E>6-0x@>5=7)Bbv+k89 zyN9D;$+SE$NLxH4``f3pSr=_3#(obm%UOdVM0?f!&qntNQE?wj4~m^l-J-kY6Q=yp zIo+zkjr20fYq}F87^JhiEkRtHg6hutZ!4@6HxCob|z|u1-H$oH7pii(3 zq;yr+7M%0*;>0-+vfh&xjJFOZOV1Oh!)oMum6yN&k<{rC#B52Q*&$SnutcDoz4-|8 z%Vha6zbE&4x~9q#IaxRXEZIY5$w5_3c~M9=9VWy3Qa09m$2E$6xUr$)b+hxbl4)Qn zO1vHeB)|ZSwH@?Yz2n`VlkBPrF`67aGa&&?|7bC~kL2(4;>!vQ{R{(g!ZtXf+kjLz z6c}}dyaDXN0Y+XPU~Z13qq8$j8j`bI8}Z1GdSLgsdS(%95{dX}>f@x-G%9tGrhVmG zj%BxPR5hp33Y74@c_$PX5tqERd>Zqy!e1k4%<`5NDn=^kCgWs;t2mRb@oJdvCpLE=hA45kYs&Fye^0Z8V@r7?$K{gH+uqf>oWi`c+Rr!~Ed*Rv z9sq&7jK4a~W|ZhBC)+NTbUnx~b_e0i%`XWrKi(dK5;Hh#1Rei$YJb~wH(ol9vBECiKay<`yc=PuGF>#*h(nL;oUKaD?ZjXe9H4p_k zKKAm`=lkZjDFKX?r!e7ihi-l~jO)J{G?Xu4UvBzK0$4=q#n#8igk7Jn8Qa^rZQK(2 z`XuL@ZAo`M=y3gdjHg8p*Gh_t>juGXtK4AWB#pyp(T@OjUSxJQFA-Gc!W0_n<*&C| z^v*lyur(Gd*JiUNpHF&nNRZ~F#6&|YL@kOWHda>TF;K5?=RaxzWG@4aGb~W>c5~o& z+mlX|iapbTJS~skDNyD zmE732)gtYItU2kK!Ge;-ZGUw@^sk=)OSw_Tzz0u!iZAoGIFa6S%=)IYn zXxB+9QRv0?p#wtG2`?~*{tEc)TVos$N_H(; zO5V?1S$tRC9c%yU;Pp0$VKV%1Zqih|+{*FxRN;v%9AB$vneh1B1!)62Ibw)}IbrkS zO1W9(;AuW@_HjQS$7`0G8ckJHS6IDlnks;teZx80KWv=4{vNvH8w>^(&=WzS8nCr* zEH~J~)XfY=npK`&V1{a1(hLE)zr|iv5-fCh$Kdb)IApaKNqAh zyk8S3>b?HE1aUdW{&<9C0iCi2wE@ zLaR}NH)>WYQzc3UX;;OL7$G1W&V=BA1HCgKF*Rju&z2LxIZ$@)U97$!4@DFi`G>d~ z1eHpz25tZaGPDtMtFor9E<=!Tp;v=ikDe2RC%-Q7^xuVO$mV2sjn1mU!?s~!XeSWB z^oicI87(_rAn0hUgfYS?Bovewdjx6GNaD;G2>p_#2g=RK{G@n5 zg~{sOhID6@7vX?hCMFTI{`qbiV*$R^?WyDCxEDSqr2K4aG&BAso zNz$KKyd6Yrbut>VfXlupbI{;lvKF+Mcv+_4Or(lwks}7BDuKPnt{^NLwB$oo$OE3f zC??FQ$o|3dm`td9ghhqsId-azZy=M0$K03C>*Tl7`KsmSj_zRZ#W=s^qtF=t!lPSN zg;cfRLwDGzr>mt(d*Z?iT79e0FGc`GLj#u{*QX=dh71_trEl)i(Nv=;OH@MvcMC<~ z3JaA;z3|fO&zn0zM0}jqt*)r(qbx-!Hj*_KmOJaWy*-n z7;Y_8h$5)jKkHy%w`}F%m)q8+nWsE&Ml|Fp-lulWw9{|FUx#Y=9J*4|;Jc!Yc<-Si zS=7+=NfOCxxy4^biES@79ykawZZd*l-{MWQYTC*Qfb=Im?9KPmySV=jAEdn}%-;ak z&AA(uiUN$Ys{q_l!EwO3!s4vu`5!Z7sj{x9V^m$@m7!Ow4F!p+N!nj7A8>G=SKpb? zyuG^&ha>sDca7ZLgXzhMdwj-IXFk2VjExV%!bXl5w0(aehOl_AFY9!E*Qdo&{Cqtl z0vsw7hz{Lf4I44DFjt+w>`ftNY*jxQ(!3^r)ttzaFyYa5k*ZN+Hb0n@ic`TN!UVS% zTI*N~3JO9+d%wVE^Kq?8^&sm$-(-M66wbL`KLJIn`gZJZZMGIm8lLHD)hnt1?cA_I z#zn>}D;jMhC7RM!l#BFwxu@ep##?6SH1Gs0cQ+C-UNw2ATl!;l5!9l_P+B=kV zl{eAbJ3CC4hlaLPd>`Q{DMt`X3N#UBL?3;LvqAJVl&*aFLeeo9dqs-yKi&Y39Mx3tE#Ft`upMFV2xol z^z?`mWT!?Z$O(j-kIkO`plPw^RMhOI4erPxVFg!L$4FC**+^R5X!+O9ZhGZo*soM- zHFxlKy5FD2IrB%27_8@%S|6V$;m`_tjZ_i9tX2lfh;>VSdD$MjGES@pZpwMR4n^ki zN2@d^xO8>f&$(^Dc*k_!n7pS#BPlxG9yNc>g||98I7CGeP@%%?AQi}+&y}Qfc5XLs zJr-v3K^b_zmO*^HiuV8gEiV4I)d9=-@)hEi)|Hu1`X;C$0tJPSrM|xV^A%4#lif-3 zYO}M=5gC50Yq-7@sz-1Fld77n&bw;R!8 z*4;dX-cMvuggNa6;}CtGV>&IbVh{ZBoF=eI#kb7vds(G3Y|wWfA0Pf47gM1K)h==lHJ903St+Rinv#x+3YQ%I$trq$ z%(u^h*?81heyT;NN22XZ*+Rt)U5oeN`ZqO((<{UbLIpXmwy%9GVyoS!zRf~9207S0 zv_7P)R`;zHhT#TvWozqAEM4!1t8OUtaGsdNXIGD<1&N=XH2@&ZUq@jFsEvAbG^{sm zv)NHLrX;WachBza>1=H82B_`h7*+$~)w~Hmm+aeLGlIg3a%X?xJ2NS^qanqX~w9oM#)|Y~3 zO91bC$i$947e*4dU+k#`Q-I(eoV4yh0Rniox3zYkF9pQ28&{8R<&&Y*E~T@>BD?o& z2RVz!-2;*5VFz_T6#Vgt&efvVKJ$np*&*5XkmW-)TR7Kd0=$( zHs07l_-Q&Wz(B+;NV3QT_*||4Xeg9P1s3RKNxPD)fj_VN19UIuJUf&I=XCLx*b=p8 z)V-Q=54VeRrYz5y=xld)#fFEnSKO7??NOSeFT1uIJ9OQ4tLfUO9oql_ZFxHN?;(&y zibb(B+{beVgl`Z2Q{l(Ar0tEJTd`unFJW@lE6&O^aNb{(*#zuuYcE@DIk8heo=@() zAaI3GkF~iu$(G~L!^0T=PIXnu9ui@TSN$CRF~3#G|FldeI&}iTS$gztR69eyF+Aj3 z;~BabC#mdhSBkd1PUw8*c&u707+Wx%%#ZWz*VK zi+v7`VMb*}|)Uy1+j5x!7O@oi9&a&zF zIlrQ2Z#22-S4c2@%=+;Q&bhExk!W&kDnP;h!LdMr)CtQvaeAYp)6rvL5F*M;^roo7 z{>az*dS*r6Z<72-cC+{`$O1pg8bs&Mn#Rg6H}j{ITpjMM8M;!_FTYREqJnxgCGuB$ z(2mSA8|}9IYTANSY%$9%)QAnM=4; zx@gWy>gi@?12(6+c`nFcY-ze5-d=FO;y&8kQABf*M)(U=$bL1I47oBHJ0%Sck~ zGt`g>3$Z@d(dmVqUPO7}>FIga7>ihf*k=@7<*p^sp-m8`z1T;)kFn%n`7Y%CfOtdS zokmZwR0ntd0oo4X&g4+78_*veAUb$m{ng^U5nSDLvEIp$)4;h`C;g|+#d#^BXJiH% zs_nrB=JNUR@lohvVqT0)N&hVPa&N9Uks_aX}ipgG{Yv{O|a|@C2}z!P@lgwx9m1 z6$v;^{?F2)g3{+L`fA-$<4Uu92Ek4)v|LVzZMRPn<=dh2ZoHNgxqian@L@|@79yyrhfa)ub%mIeBATUt%y8B zZn=9be4M~^U-Bp0uMe-mWg2CXB4kSNd7RjDN8-^W!hVnv=~V#}EqmA3m(q}Eg_|ek ztjXv!P<)LBWXouc75hegpIYg^G*090;v9zfyB!nL^Tzpd=pHRtMQ|u5#isDzcEf9T z@93f-16*7$&o$U1ev8!yq?QeC>L+J?dd1w0OT^GcEqWpwyA5RnD7ADT$(}T;>clwW zvnqdgoS#x)9-Xca%sE>Pgw)^SWDJZ_luJ=q|gdF{pVNPgT?3 z700;09w^kPxm=7R*exyMCYKy!46sWm)D87+Vxp}@&A@p=K|o<) z;jArmgGB}ee3gZ5u|sUFNo~i9y|#{2k)I1}@h;dL&wF1M?$2M1a1ERyhh>fv+}z@} znnmB8u(i|I5fE@nhP7N;x5L)r_kt0&*D;&X`NT$32lis!;yh%%e1vBH+BO7&tMi5S zvYQJ0;WpS;MtmCd^c)hruS$o5D{vd7wHNw~OaB0Ac|~Ri0glonqisLJz4+Wq8T5V$ z_vXo1Lf>Bd*^rVz2i2DP=IxazmY+CwSQ3Z2)s!X^I~OTZpRi6ONGE$(kBqvS32@G$ zsIOv=8rerbYKTC7kX(2RG!WsRK-1E1yHh8O%ywvw7z+e8LGv?v+WG_KvS&eqdoSJ1 zI|Bm10(w!QpQR!1NaC10rJrlA<8}fHkfKuIq}m;(5vt0nP+$qPwY7na;NG11Di?7p zBA)!2%B;ueunoNdX-6R+=^{lHwWV7K1Ry~wO}SW}YTuqFTVk-nj4kq1WL)$JT={2% zN|H(&L>B}oVFEQ75)`(-sOB&0Q*f6o9F)ow$upieh;QJvkn4QwVG`Rc7(i)BpnT^4 z`KIFFzzG-$MdgrM9oUApv4sj}a&*`Qpi+0YvcL$hZuw&Q!qtNLW}{01lS5 z>V3M*G2M5^`Z zumfaD2Uq7-G)ODjMG7gv`KI#m@ukt4XlS@mrtiu^8~9y7Jivncudnke zGYBdC94EYo)cm(xKu}PSEl&U7dRVoai@&ePcgD^>f6%=}UqNI`FjjbgKuhXQJVjPu z5iqu_b4UWK}H*cwtFx4{$xr6Y;jS9|YF<|K#gim4BUM^$-hNy15gacbJ z{>#X=68%9k++b30sr@^DjX04o4U~Gw5wO@#;Ay!d3)9v!M+RnKS+$-$Ei%JhKR>ut&0~1 z7}d9d!)LQBZyug5*j@K@!(sAc;G<`zB}uG;P4qU~SP?O>lZ>8@D~+T`sqd~Qk>jZ-l9f{hL616?c-WVGgX?6FbX`tUG0}k@?gK4MCM;(=?F3X$g;^5 z(X?CxG|ppX@_2JszslCis>EKHxNFQ|jnRtBG0bO)x*!pu672TOW;)%VzfUC6dG`m3 z!&Mhz*d)vg3XiZ(EiR`{nEFQY5OL))0}OYC+H*Eq*;=nJZ3?32$erEQry7cxJH=>2BOGH@ z_Aw$>2zLsIV$O5g4967GJO*O<^Y+Vgrr1wo@VGpsD^8CO999YuG%dC2HBX;GYGZ;Q zrKOfV$%qg+g9hPG?CYtudY*C6`dt^ygHD2KrXnJ>IQy_Zv0E_|hkci+^JX}ZtTzh~ zuePS-o)+pfevv0ov!PbXj!7}xx1EiJK*FD-G&!8@2>sn&uKbdh;o}Bz!9n9F!|Udj zQM0LMzy40-Wgo~;$^F4tD+Ig&$c_HqHW4Af99(8*mITi=0UMX1e0tqO2+JxD--fC5 zdn8IiLG`6{65G@eA`!t}g)*BL%lYw0zn4*(kJU;>Q75G9hlv2envbukJIhCpN|Dml zCQaqT>|Oj9Lmo9U*30oMiYVKN!%r*5X4ZoYqDgT_nbdYFXQM4NflPBx{NM?L_|yBX zhTA**E?daYOwIu{ zJHsn>OV;jzRg-<+)OvX`i&4~Dj}%KXG;cbHmpyWIbIqj0 zuYeS1Nfz)subu#Zd$3eoZn9zVfu(W`v_BMOAX6@f+e@ykV48`R_IxQ2+<;q07P0WM zxJ5(KDGUA6`7)af;N#_l!$PK>-gMVsrB^}UJBPq6ztA}09#VMg{+gBAq-E8C)k$VP zmd$FoU}=`%$m#kRcCj?EwBk3s@G+9!{C8qRP-AIzj%oHVDrKZM$;tLHP!b<{P5)GC z<8qSLagJTmzW$F!J{beRwEA>PhYp*Wmvwc=QL>S-Eu;PRunP+Ds&ST`;rGW}a`0x> z`8FiVaWR2T_st9<2hwHB1;1L#_6Z_=YD<;d+NgD*z;mtp7ReMZ5AvsR-3q+%U~3>4 zmkYt+L&o&f&Y@o*Z}W-^2ZiTqJR^68=Go#;I?ouq(xBa9A23{61AsmTLW$mxY5u6-M<7- zS(upHf@FiOGA?S~g+npip2XcR8VkqB5;9b%+m(1iK{?`F) z-2z=PH$C9rz!A=#{AEPTo5%BTb|T&a2Zd|>VY{QGyr8Znw=_vI(<|U1swC){8%@Jp zkgZiiTh>+fyRnK><7G+u6&~)cxtzGHe%NX+N1?_(CU>&b9@ME$YP|KHo(KK{XM{RP4rC2<@8cUnwf{WsDSAdvf8SleU5GFpA#!HmL5k1x-O7*w1g_;Ez z_3veCA7_^VUxa4z96#qJ6)8cc+>W9NzJNsTR3^jKJpGl{I}YXiQuDpObyq8Pc)fA_ z4G3;!(|gs8k&zt}$=L}H*)g639lrq z$Z8$z0?A6zkewQv(Bp}IwTp7CU~7B_a2$BF-CHuZjMztC0OcHKY=Q|(fX^xb8cm>I z1CH21u@#QTtTX4zFQ@d`qUZdu0C$aJY&y9i-#R>rTTsny?I_I=f=m89GL7ea%JSPY zwfrztt~)KjQ)1?zK1%i8VzrUGBve9y-YHEjy{p_hA({8?{Cv6nt@?L!<>&nhZPA*q zM!`d)Gyxjkq5RtZ;I($q?@dyd3kIv?{8` z-d%?IvZ%6xN6uR$94HBvYdJQq3FpAl3x%EOUmTYcy_K>THaikAPJFKD@R`e|u5b9l zf8-M`@p~OIdJ8&`&itMQed|B-nQ@lCn`5YdZ^Tk8;nOayH<|4aQ{AMLbgoy>rGrii&Cxl z{h8+XVqq$$y#+xn|G7H8p8_TLKOrjyT0%{IJscd|#K;JF7EA!;KGS^NsYRMXNi6~X zGOG$eB`Z9YARxWa{97we%rNvZ{MNhM9bbyxeq>R9m?+sR0{4{Pltt}LrP{6S~U%7w;7{k=6|!zY;9eQV$2b} zI6m1jP}A?gJ&OblO(+|lq~M^#Ld1++%wxSK#s5(94;Q7`god|JghP-YDSTjF@ybt> zG-9?_(@839(=;$zxNST2ILLDwCZ6@1G*Qju-+JeUgR^T*>l!X>YLt%vaoi^=FkV$= zNErFoD5bH-HztN7FvZf+5@z;h{jSEeeTJWBBstlFP|Pa%9G7jid;8~UKhH?oPh!2= z)MlF_G^M;?2#R# z$`SB8OS2M^0jOss^9_qdd)8JzVX|iyG%L=fIdBli zJq-CHX>@lNHAs7h$`Rb9Ek9qeQgO#mZS9JVj@nBci-S~?M_WgMq^fF> zf#Gy#M;3XYZHA5kmgy+O%T0mc8pHP`hzLlzzER&8g9p}-5&x4GWB*y(vH#nBC$45z zdE$S~4>Swzf`s_{1JO_n8ZAjl=E*HC@$}U;ziuj=LntCMaRq8KoNvB0)}5|yUbN3W z+*Qwhhz$)7y4p7IJiJhMTS?6_XwDyog%YAO8qjO|zd`W8y&HN;tRxK;ZcE&t1q_6( zeO|eCMeY&sI6Tfj+o|YY&QRe&f$kqlmi9X}D14yCFikP)L7XA>wY= zO#U2}jWNYU!bP!toYMN8;|g`O{V7O4p+!u`o0Ckr_beJ5Ux9BD^V#`Mkkl+kUbG*Y zVLu)C2_!{;1r`3Fd>D`baPP<5Tcs1IC?uz4YtY;L*lOp$Fz8Z#J9+nbLQXaxT{^!3 z``(WSfcib?CbL3|;LL#RU`JyPYzD!DXmrB2f$U@RFi}3CcGcOD-}Udg0LKRkyC;}H zx+g{-5VN8*HSJ{Y`tU0hV$Yti3Fu=1dsk)kTwotDO8aS`Bw|!{RzFl!CTh2yW+7Yz zV<+Ue+oOP?Q^ebB$1wj}4%dg*3Gu40phI@nmTtRz8^u?yIDn-0qlDhu&V;PF?45)! zvoRyHl)9dRHMyFE)_HfjR1dHqZ>PjQ7J!L;Le&A3tN< zZgpi<5uxQ=YHuydO&P#pFTtBrG~0dW8wa_=v(p=gwdMT~k=sqJUB9eXf;t)()i}M^ z6jpbMr6+iAPl-+>?!LE0w{uZfe0{rfp@m#j_TA9(G;+Litp;7gbr`9qczqETuXbSJ z^?Xd5QvP8Fzpo=EmEHg*b%$ruNQ-Ilk+_FD6pH=h`{dLlS|2L32;nD*AfQDuk0#Zm z(ibP)mV-p-%A3z~KkdzWT}A!6P@&iDEt;9)!9o(q%W%R_t;HpU1zz5*RJr(*NAjkv zT?6H6k|W&Mz(7Vn#l#})Vk(3TkDw^c^Ah)s4DbTSL>ImWGz5 zx|Q9*>;O#OGPVL|N09>v^SkV#r)8#%0Vs~*IP-){)&+@Ns*87tJQ(0l{j9qi8xjeI ziW{#ePR!N^h%icrgB`4=ZGBX8-^MQId)gsLW|6ihoqsC3E$w)-R84qH+ED> zy9{1`teAiYsEdavq@~(Nq4A&0P?<($G8R2s)JL$Q8~8y)xSADWOxuJ zaES?2G;>I!2PLE9Z}%9!;Rcgs4cuJ|C;4&%c+@p88XxI4>ydsPmyKW7!s&!1|G~i@ z0mchy!Xc4aXi4eN!|%ggpTSi>^8a+SJ;ioa{b}HPyCU=omVYeB3vU1{GEw{n0>2`; zNvJqmTK@jXgbHM~4HP<_SyWSLWK&j4oQ}eo8;FS{A0+n#=`NjaS;p4XE8L4~%KK>yz>7GOj7qrdG>y>WY#;n}>JKVv z74`0=>-m!qlQA?12;=$}EZ6^w%~0YJ%m&{&P<2Dqmm0Jx1)HA(MTlvAeS_Hso5c;m z08yGf{8;FJZ3X&&5svz=zhyEOjRrE)j|EmrtiEbzW8;KdmL9WPWw_CDK4b1Fus(T{ z)*6UdS}lLi?u;z}djk39)xUB~Vvlsh3}oT76XR`%Ocmr8AG4Yb4`udp-Vd+N z1}siidHVA1_YbqmP#IHZ>M=!;?ps}PX33isKc(sYqe9E3o)%5c(RQG5T#lJ1wHfu~ zuM%Lw7vYNC?sK^4Iw($tx9IzD5a}bsEhVU-=>01nvAYoVJ=K&PK31%DpA6SKwShzSSB)5mO}KgC0~@5Wz^3*(z}q{;E=a5( zc3C5Ci{D}Y$|qY43n`%qxPBY=(xg#@%$Po)TN6cMP;-B%TtmHx-gnkVfU!l6J6k~A z)xVMyrxu^jOUvm+#O9muGa@_hP_Mk;QP$;8)ZS=cYBA@}u8Q_#FC%dj;=7geY^Nmn zG;Y5q{5l>;GoN0+-9Ol%s#YzR;-UuP&L+`%@80oZK4;iipqcV1}HLhs$V* zm?5K_&s5vog#Y*ZV%X4Hf)V+reO(K+2nh*dEu9fKK^hc~O*R zhl1Rko1SK(?C=Tt_%uyfId=sMA(e$vG0E;x`!;N?@F}wFGccU$to#q_hL@t#b5GFa z9f`a|186d)5hj#~rOO>&eK@%1_jV0IY`X>?PY!PQ)63T zA4xnPb;mxvTY?@os?J1}5x7&7pMT2B$*XGTj-kgmwd+FcjWQF|NiV;meo6!$Dvhg!T z?oXxC^(q1?Xcsj5DhZix?hMkek|Bo)7SRe!AuYjJkp zE(=gv_R)AK2o&hTw^+(PAKR7b~Ar)He(1$>slN$D(iH% z`*6CsVdzfy`hl8}a6v0{B)O^8bWMMC%mkbC|GLF+J5E*= zB-8|A{kC0%g~5QTq;wNT@;#wZKoqaB|XTPPL7ZFQaWkWCdzT;DHCio*vx21O zKRDxCG`3Y#;QtAboN$d{1P2Bd0n1>BkAvjodU#fI2mFEh6Ix&fD$~>u zQ!56i3m~|`K%RWEhOqH(ph}h%0%C|%eSt=gLl;pg`k!D(WGTOY-K(UgCdLg@ii`V` z76KN#mW>mb#hHqi1^xHWz(Rxee^WH&Q_ZvaR*TMw7`cp&fPlctJ<(^#HR0z0#pa>vk_JTo=xypa`?OQb~DHg$( zbqeO+)*YsRI^G?P1-U!^{{B<^Z&r`%FJ>WYF?3>if}Oxy{o|B89ZUOoS`SV@;QnWi z1N-P>Q=Se%?9N_{K(GPKthJR!kDdn%MC~7wfIBPY?kB>jRyJb1h!NjDm|1?{LG-%L z3E)BD@s(>Jm7V!Vy+RNvoxvf0%~ot{KiGpYCgDbtfR;}rCcAqZF&jx^JaJ;4no;0R z8p)xR@}b+^F7Pztw0hOkg!$<}a-E|GIa`|#*LReh(AP*D{8Wc1^$1@8B`vTOUMg}0 zU}7OhSH~q*JsyR@hdKIO>tzJQ;K0@09Xnft!@$VZqySb-bD#tC(E&{YE+HWX&`zU* z&U71h3A%ecK+H8@;Y!BtpQ+nwt3-VQu1-ssBR|{QEO>r#v9ZC&%wluw?B(gX{dBR` z?9|@dOENep!p6on@Pnz^H|reYVZfs=6T})`VsZvdtgdmf>DICDaV~Ix_rot~fL4^@ z!$>qWRxs82>UT6n;r4Q$$`;xaij zxZr3`d@0GTz(cZY=%W;ZveinjJQeIgNTrw{?}^W$bBw`(_YlW)99TxN^B7o8?=e+J=POsP8nf1EpD zxVBck|45$xa3lfK)%@Bx9jqGIQX!+3fz`ZyhTd$fWEd8BafS`U=jd!doWI)AY~Uo( zI=TA(c|uoeyx@#__go>^5uq#Ggn%QR=j6@uWJhw5zmo=B9+t}g{oZ0ezfU%%SdLmY z*!X8s5SAOf+9{$H1%gJxJt>%6uDHRmPua+fz8Va-x@*~Fi;{R{U_P|Gpi<3> z%CEkG+5WZQ=@}RY#e|R4?J`RhZ|2Jlagk9KrO$(Wvrf9{AQf%tD}HOy`Hb|p4AGiQ zrBQ7I_ZSiPzpE;g!Tz7R((tO zGf|gQNsrTzpkhbSzM9=*8mZroHbUAN(qCqZ)AT&gsxRNS2xamqLJuYk>crQJtM?ph zs}k4apP{SQIO6RAhYydVQxGxF=Rp~JyU7(>R7cDMK@j)3VGL2vrk%|cp@Vu%BMlQ7 z#TbP@zudN(eMH|mtRD{T?1ctY41_S4nX9@Q90y;Wg830Ufu-&@U6W}bU1%N-LZ}wp zoWq$mx~CM)-#?(9nPqLXw+RadJA8Fi6X!%5sL#L@@4Knc;a`t4IAGTH>sNe)IR{bd zlt3Lr_4asD6I8W@CC_h3%6~D%t8a7dE^u141{yrTHnsog<6ACzrrQ;PE#RS}FSp+O z#@?D79c4d7cR_jrLL0k$b(Pi4=EDex^~!s}L9}30sq}!x1!zhLc$^)p|3bHjMtS6Q z*FYOgzC96%OG|$z-~|i`lj~`DX90yL6mIOy%-CyX&(Bd4;?WY~rM13^(`fO`x+QEZ zlQq%mwQ5i5sapr@IFMX*;9wy1BeI$CE#Iz*TQq8ZFKY%0=@CsUo7FUSPRFRTu=A5b zg$n*C8b~06Hh|@1_*aUMfznSXK&7IVciwM4dA9#<>XdxF_2$FJ8rM&MJ@x1@(zXt9 z$kIeC;{y30CQIhYVG{;|V4+1n;eeWq$W8lqJQp~GrK|CFNwETMjTic>Zg|2Cz^fRBJz5&RKS(yH2Kn*mR2#R3JOD!#&TGIXUaqSryzrNRA zkM#p{MUZ~#j>alUvi+FXMZjiq%F1eLP)VAu;BW=gp}#>5xehQeV2k>Cdj+YCd{gsW zG<6w(bnJCvWo)M18&`aGJjtrk($cD`+V^J`pyAi}DLYMCS)SlK-l9z(Gl&}YYrvCg zA2v-JQ5B2Vv`A5c8w@!9J>P~ujmTQyZ~qmb(to&h(r%dCCfncqm+6bhi@@`si~s2v zi3VP)u&TKZf5P}LNT^)6sQ*VP21;i!DThNFRkg157K#dBU7al%3DjUc2&r|?$U*{11KkSmE zDJTy`^QPC~wxfy5s!L|fs{ZN9+YXcvP{%w61NwEPDZ>YtDT$7O?pK19Kz^?3PbYcm zTH1f^4#*Uam5qiQS1r0;?BIeyDY^v(COB{q#Q%6`Y3i8MjLN4*ix|>G(EqqyJ&;17 z%b?kJA3uxa8_A;>|JkQEApSir_J4525U{V3XwVB2eE;Nu3@C0hq8eCE<2bTJKjB7X zWfcQ={o@1nYfovWK?c^*u6+An+%m_uJZI4&MU4NC9kVD=!&-HCI7p1u0L8NSu>*_7 zbaga6k0`|}hbcan_vf3%X#Ze1SXlnI^ZFHsqXGhJfhOKa@KYcyOj&{9n@kX-aD5mX zNOrwKQ!~QDsV_W{&pQ*kRIIb%7%1eL6ji7S8(yYq!E5J}>trR!in=r;x@_#D{upR+ z?8WbB;6a1QVAJ5zB!#r7ANb}dEKhzP!Q%vkr404g$4wtAt8`1H)P|6OFLN~ZRXNWd zUk8$Brw1gJproR}ucg*2q#X?oqZN9kYV_#j@%>`+DLFiOlyqc4f0pq+NaKGf6MOnn zp+9AEzEn&hu<`Nnb7ZeVOYY+4l7?QaIy{|XsOAx;9SKblz!7__eBb|8Yp>U_bp7Aj zJL{;pwsp(nZowTA+@0VF65QRTg1ft0a82PFAPH{4so)SixI2X40fJj^a?d$q+}H2* zJ@@vzuU~iHe;BZ;1~taod)J!lH@`K%q2?!4U0fs@eB;l);Z@~kO6#U&`R?nUb{mG= zI8S)^wgQ3L$+YhNcac@f&TGUwrsR>al-pXYi<1n|IKsOG>4o@;##N3(iCsU%Vms0j)Od^^Yfr$`bUQcb3rfK?8(MzC-O&l_W%4(9 z#^emrS8CWi689gLovqP|*W{-{{Etv=%#qjAm?^Y>U z8RQK=?H;Ak8Kwz#uH?`UIF}$*|J-DvRnb=8+&Hi^Hg|AvAl&B>;=)xdgCfjucg4+l z5x~m8fjmj_{51kK?d=JwrRVpmd|==3B)YWTeonRwyB49^Qs3u2eU%{FgQR<0^Y$&l zw$$^0@+exA+>_4n$agk~7z7E5TkXxM1S}$kVknBNhK-ihk`UvB+zg^}X@9}{ey`jQ zoQT62A7bO}jp{tt4w=Im~P6cpTe&sec;6@M#g%;$Xcq zEIQocaG{~{x0~@)clA_o#;}5+a_lp*bB1SG)J~s_!rRWcyqmXI@#t9K>{*Drk~dAY zNN?B;J=ev4D}3g51Fe}KN3{}~jL4j)vZni9;m8ClM1>aFBYx-G0L?k8cppP(-8yN~ z+35hCLCY)jv=%;U?Ju7QZg7&u)GG>~ZF^SR*Jh@EWPY9eK5u+jimQMQhq3Bm1vZ&B z?m7Eup-eIwspPoXz4<9G2>xZ}87sH8K^3I1KMWx=D?pG!-x5@wHU-0#E>Ujj9WTcS zeo|3DV(V1!jV*hk;AAv@Wyvyo*A5z?)C|Xqz4GlE)ejknEHQ3) zNyRvZDO@-~Zi(P$FdDKrw8~tO&r?!qz)7q@oh8p2;R?Lno?7Xxcy1?GCckZ+HrNoW z9^x`>evdGN{Nx_jDL;tEW(GJ#3CjhXO51})#IO~_H1*s9YvpMNeh?C6*6bduO#1*5badAmYcl~N<5ojDG^!Jb}8op0e~ zHI1LIYS`J4?NAE|3ES9MA1NIz3)}Xbm>u|LV_id5`741_?h?jd#Ux*a&OI=&903Q;mPR{25C{rDBjI)3pLK8j+V=1g1j61~ zMP?#rgkCt=3zaR3ii#HpzWUuj7O81(v(ny9etjwb@=@UxpDxXk|EBO9k*5O9aY}pt zgt`M`16soon;Lu)#0=&f%KqQZgfAPmMTmh7H zdASQLco=AP1?I;^UbG!ydU9HTpI_%u%0<_@_(3L&NYE-3I$_% z&w$izby;Rq$$Wq_ZBi%qXGuKENYGR>fbFV1GxuZ0l#BsXT4vGCMG81oZ}yzGK8P~n zK((M}c{yivyIRLqD!-sfGHulzY6HR!QrFR#Vi=!#1U?kmF_A36et+`x{J%L?|6T}{ zjLQ*9)HZcINi|p*P7TiYF*TguP~ zmR0^0Om!3uy>X<1NAe~aLye8RL!I~gEifH;Y}h0w`a4>~pn6)5kS+Rr!?Ut_cFyW| z+BS4n2DW-hKbV9L+)R?}wzCP045k1VRFWiwVpHL*059g=(EDC+G=FZv5z;Dr4n* zVR|rQlYWI}DImS-XXwvFE*4$-;wc;s=z;L=yw%!KkxXF^k@>ar*MmUoHAC`vIlMC% zU_-^uovDqp%1>hEv!Gmw@#V*HABFhi9us(6t01Al+8VVTUc4nMKJnX�fqPsc^ru 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 literal 0 HcmV?d00001 diff --git a/tests/e2e/large-firewall.spec.ts b/tests/e2e/large-firewall.spec.ts new file mode 100644 index 00000000..1b97a77c --- /dev/null +++ b/tests/e2e/large-firewall.spec.ts @@ -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[] = [] + 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) +})