diff --git a/components/data-grids/certificates-data-grid.tsx b/components/data-grids/certificates-data-grid.tsx index bfddb32b..71247363 100644 --- a/components/data-grids/certificates-data-grid.tsx +++ b/components/data-grids/certificates-data-grid.tsx @@ -1,8 +1,8 @@ "use client" -import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" +import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" -import { useMemo, type ReactNode } from "react" +import { useMemo, useEffect, type ReactNode } from "react" import { type ColumnDef, useTable, @@ -223,13 +223,22 @@ function CertificatesDataGrid({ certificates, serverMap, isLoading, emptyTitle = const table = useTable({ features: dataGridFeatures, - manualPagination: true, + manualPagination: false, + autoResetPageIndex: false, + autoResetExpanded: false, + initialState: { pagination: { pageIndex: 0, pageSize: 50 } }, data: certificates, columns, getRowId: (row) => row.id, getRowCanExpand: () => true, }) + const { pageIndex, pageSize } = table.state.pagination + useEffect(() => { + const lastPage = Math.max(0, Math.ceil(certificates.length / pageSize) - 1) + if (pageIndex > lastPage) table.setPageIndex(lastPage) + }, [certificates.length, pageSize, pageIndex, table]) + if (!isLoading && certificates.length === 0) { return ( 50 || pageSize !== 50} isLoading={isLoading} loadingMode="skeleton" onRowClick={(row) => table.getRow(row.id).toggleExpanded()} - /> + > + + + + + + ) } diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index 3a8d68b7..a4859a63 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -20,7 +20,6 @@ | Файл | Следующая проверка | | --- | --- | | [backups-data-grid.tsx](../components/data-grids/backups-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 | -| [certificates-data-grid.tsx](../components/data-grids/certificates-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 | | [communities-data-grid.tsx](../components/data-grids/communities-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 | | [data-collection-scheduler-data-grid.tsx](../components/data-grids/data-collection-scheduler-data-grid.tsx) | UI строится из11 SCHEDULER_JOB_KEYS; fixture1000 не соответствует источнику | | [filters-data-grid.tsx](../components/data-grids/filters-data-grid.tsx) | Порядок/drag и операции до ограничения DOM | @@ -44,7 +43,7 @@ | [wireguard-data-grid.tsx](../components/data-grids/wireguard-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 | | [wireguard-peers-grid.tsx](../components/data-grids/wireguard-peers-grid.tsx) | Потребитель API, контекст/детали и fixture1000 | -Осталось24 специализированных grids; ни один из оставшегося перечня не принят этим документом на больших данных. CompactDataGrid дополнительно проверен на повторном получении100 active probes: страница сохраняется; сокращение100→1 ограничивает индекс. Speed probes и uptime resources приняты в блоках12.3.7–12.3.9 и исключены из оставшегося перечня. Следующие большие профили: probes-schedule, затем сетевые/admin grids с учётом границ API. Общий журнал: [ux-ui-progress.md](ux-ui-progress.md). +Осталось23 специализированных grids; ни один из оставшегося перечня не принят этим документом на больших данных. CompactDataGrid дополнительно проверен на повторном получении100 active probes: страница сохраняется; сокращение100→1 ограничивает индекс. Speed probes и uptime resources приняты в блоках12.3.7–12.3.9 и исключены из оставшегося перечня. Следующие большие профили: probes-schedule, затем сетевые/admin grids с учётом границ API. Общий журнал: [ux-ui-progress.md](ux-ui-progress.md). 12.3.6:1000 speed probes baseline —1000 rendered rows/23590 DOM nodes/3741ms dev ready; профиль collector-probes4 cases и ESLint прошли. Далее12.3.7 ограничение DOM speed grid; этот baseline не означает принятую производительность. @@ -67,3 +66,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич 12.3.14: history200/73075 JSON bytes —200→50 rows,7583→2361 DOM nodes; final dev ready1271ms. Global TX sort/page4/refresh page2/200→1/320px/ScrollArea/axe проверены. Это DOM приёмка, не принятие legacy null→0 rates и общей очистки двух GET при ошибке reloadSpeedData. 12.3.15: live расписание на1000 rows дополнительно проверено при503 на page20, freshness/cache/recovery1/no POST;50 rows/739 section nodes/1778 page nodes. Demo ProbesScheduleDataGrid не использует API и начинается с3 INIT_RULES; исключать его из live migration, но не считать произвольный большой demo-list проверенным. + +12.3.16:certificates1000/329034 JSON bytes —1000→50 rows/29666→2145 DOM nodes,1098ms final dev ready. Whole-catalog search/URL/reload/sort/page20/SAN/refresh/1000→1/320px/axe проверены. Осталось23 manualPagination:true файла; закрепление идентификатора строки при горизонтальной прокрутке — следующий UX блок. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5e63b57c..adb446e7 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, затем users/backups/network grids и отдельная достоверность history;12.4/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:имя строки при horizontal scroll, затем users/backups/network grids и отдельная достоверность history;12.4/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 6123f499..72864443 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -36,6 +36,14 @@ +## 12.3.16 — каталог1000 сертификатов и refresh деталей (2026-10-04) + +Fixture strict per-device certificatesStateSchema,1000 сертификатов одного устройства/329034 JSON bytes. Baseline1000 rows/29666 DOM nodes/2689ms dev ready; после client pagination50 и горизонтального ReUI ScrollArea:50 rows/2145 nodes/1098ms. autoResetPageIndex=false/clamp сохраняют страницу и корректно сокращают1000→1. Воспроизведено закрытие SAN при refresh: autoResetExpanded=false сохраняет детали по стабильному составному ID устройства/RouterOS. + +Проверены page20/Enter/SAN, refresh страницы и раскрытия, поиск записи1000 по полному каталогу с URL/server/reload, глобальная числовая сортировка срока, ScrollArea/reflow320, tagged axe grid, сокращение и отсутствие POST. После последней production правки19 из20 browser прошли; narrow layout прерван разовой UNKNOWN open PNG ошибкой Windows. Повтор всех3 layout прошёл без изменения UI. Typecheck/ESLint и production frontend/contracts build34 страницы прошли. Снимок просмотрен; из-за горизонтальной прокрутки имя строки уходит из видимой области — следующий UX блок сохраняет идентификацию строк. + +Следующий блок12.3.17: штатное закрепление колонки имени сертификата и измерения pivot при horizontal scroll, проверить доступ к остальным ячейкам на320px/ПК. Затем users/backups/network grids. Осталось23 manualPagination:true файла, включая demo-only и bounded источники; это не23 доказанных дефекта. Достоверность speed history, итоговая матрица12.4 и внешняя приёмка12.5 остаются. Коммит локальный, без push. + ## 12.3.15 — live расписание1000 и отказ на последней странице (2026-10-04) Уточнён consumer: ProbesScheduleDataGrid только demo/INIT_RULES3; live /probes использует ProbesSpeedSchedule→ProbesSpeedProbesDataGrid, уже принятый на1000 в12.3.7. Существующий большой профиль усилен проверкой503 на page20:50 строк и951–1000 из1000 остаются, полученное время не обновляется, отображается последний успешный список. Recovery1000→1 очищает ошибку и clamp страницы, POST не отправляется. Не добавлялось вымышленное live GRE расписание. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index d552bc8b..0babee93 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -265,3 +265,11 @@ Fixture17311 JSON bytes,50 строк в DOM; после исправления2 15 browser collector-probes/probes-schedule прошли: включает initial unknown/malformed/timeout/permissions/320px, demo device/tunnel selection и Rail1001 обе темы/границу площадок.1000 live rows:50 DOM rows/739 nodes секции/1778 page nodes/938ms dev ready. Typecheck и ESLint теста прошли. Production код не менялся с e5cf057; build34 страницы принят в предыдущем блоке. Исторические generated screenshots восстановлены. Следующий блок12.3.16:certificates1000 через strict per-device state, pagination/поиск всего каталога/числовая сортировка/детали/refresh/уменьшение/320px. Далее users/backups/network grids; отдельная достоверность speed history остаётся в12.3.14.12.4 итоговая матрица и12.5 внешняя приёмка не завершены. Локальный коммит без push. + +## 12.3.16 — каталог1000 сертификатов и refresh деталей (2026-10-04) + +Fixture strict per-device certificatesStateSchema,1000 сертификатов одного устройства/329034 JSON bytes. Baseline1000 rows/29666 DOM nodes/2689ms dev ready; после client pagination50 и горизонтального ReUI ScrollArea:50 rows/2145 nodes/1098ms. autoResetPageIndex=false/clamp сохраняют страницу и корректно сокращают1000→1. Воспроизведено закрытие SAN при refresh: autoResetExpanded=false сохраняет детали по стабильному составному ID устройства/RouterOS. + +Проверены page20/Enter/SAN, refresh страницы и раскрытия, поиск записи1000 по полному каталогу с URL/server/reload, глобальная числовая сортировка срока, ScrollArea/reflow320, tagged axe grid, сокращение и отсутствие POST. После последней production правки19 из20 browser прошли; narrow layout прерван разовой UNKNOWN open PNG ошибкой Windows. Повтор всех3 layout прошёл без изменения UI. Typecheck/ESLint и production frontend/contracts build34 страницы прошли. Снимок просмотрен; из-за горизонтальной прокрутки имя строки уходит из видимой области — следующий UX блок сохраняет идентификацию строк. + +Следующий блок12.3.17: штатное закрепление колонки имени сертификата и измерения pivot при horizontal scroll, проверить доступ к остальным ячейкам на320px/ПК. Затем users/backups/network grids. Осталось23 manualPagination:true файла, включая demo-only и bounded источники; это не23 доказанных дефекта. Достоверность speed history, итоговая матрица12.4 и внешняя приёмка12.5 остаются. Коммит локальный, без push. diff --git a/docs/ux-ui/screens/large-certificates-320.png b/docs/ux-ui/screens/large-certificates-320.png new file mode 100644 index 00000000..d2d0aa68 Binary files /dev/null and b/docs/ux-ui/screens/large-certificates-320.png differ diff --git a/tests/e2e/large-certificates.spec.ts b/tests/e2e/large-certificates.spec.ts new file mode 100644 index 00000000..bc1d4152 --- /dev/null +++ b/tests/e2e/large-certificates.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from "@playwright/test" +import { certificatesStateSchema } from "../../packages/contracts/src/certificates" +import { certificate, certificateState, server } from "./fixtures/certificates" +import axe from "axe-core" +import { mkdirSync } from "node:fs" + +test("Certificates1000: DOM, страницы, детали, поиск, refresh и320px", async ({ page }, info) => { + test.setTimeout(90000) + let certificates = Array.from({ length: 1000 }, (_, i) => ({ ...certificate, id: "1:*" + (i + 1), rosId: "*" + (i + 1), name: "cert-" + (i + 1), sans: ["alt" + (i + 1) + ".example.test"], daysLeft: 300 + i, validFrom: "2026-09-01T00:00:00Z", validUntil: new Date(Date.parse("2026-10-01T00:00:00Z") + (300 + i) * 86400000).toISOString(), trusted: true, status: "valid", keySize: 2048 })) + let calls = 0; const writes: string[] = [] + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (r.request().method() !== "GET") writes.push(path) + if (path === "/api/certificates/state") calls++ + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificatesStateSchema.parse(certificateState(certificates)) : path === "/api/certificates/acme-settings" ? { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false } : path === "/api/certificates/renew-settings" ? { enabled: false, intervalSec: 300, renewBeforeDays: 30 } : {} }) + }) + const start = Date.now(); await page.goto("/certificates?server=1") + const rows = page.locator("tr[data-row-id]") + await expect(page.getByText("cert-1", { exact: true })).toBeVisible() + const sample = { fixtureRows: 1000, fixtureBytes: Buffer.byteLength(JSON.stringify(certificateState(certificates))), renderedRows: await rows.count(), domNodes: await page.locator("*").count(), readyMs: Date.now() - start } + console.log(JSON.stringify(sample)); await info.attach("large-certificates-metrics", { body: JSON.stringify(sample), contentType: "application/json" }) + expect(sample.renderedRows).toBe(50); expect(sample.domNodes).toBeLessThan(5000) + for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "…", exact: true }).last().click() + await page.getByRole("button", { name: "Перейти на страницу 20", exact: true }).click() + const detail = page.getByRole("button", { name: "Детали сертификата cert-1000", exact: true }) + await detail.focus(); await page.keyboard.press("Enter") + await expect(detail).toHaveAttribute("aria-expanded", "true") + await expect(page.getByText("alt1000.example.test", { exact: true })).toBeVisible() + const before = calls; await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect.poll(() => calls).toBeGreaterThan(before) + await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled() + await expect(page.getByText("951–1000 из 1000", { exact: true })).toBeVisible() + await expect(detail).toHaveAttribute("aria-expanded", "true") + const search = page.getByRole("textbox", { name: "Поиск по имени, CN, эмитенту…" }) + await search.fill("cert-1000"); await expect(rows).toHaveCount(1) + await expect(page).toHaveURL(url => url.searchParams.get("q") === "cert-1000" && url.searchParams.get("server") === "1") + await page.reload(); await expect(rows).toHaveCount(1); await expect(search).toHaveValue("cert-1000") + await search.fill(""); await expect(rows).toHaveCount(50) + await page.getByRole("table").getByRole("button", { name: "Срок", exact: true }).click() + await expect(rows.first()).toHaveAttribute("data-row-id", "1:*1000") + await page.setViewportSize({ width: 320, height: 1000 }) + const scroll = page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]') + await expect(scroll).toHaveCount(1) + expect(await scroll.evaluate(el => el.scrollWidth > el.clientWidth)).toBe(true) + await scroll.evaluate(el => { el.scrollLeft = el.scrollWidth }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await page.addScriptTag({ content: axe.source }) + const audit = await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[data-slot="data-grid"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } })) + expect(audit.violations).toEqual([]) + await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }); mkdirSync("docs/ux-ui/screens", { recursive: true }) + await page.getByRole("table").scrollIntoViewIfNeeded(); await page.screenshot({ path: "docs/ux-ui/screens/large-certificates-320.png", animations: "disabled" }) + await page.getByRole("button", { name: "Следующая страница", exact: true }).click() + certificates = [certificates[999]]; const previous = calls; await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect.poll(() => calls).toBeGreaterThan(previous); await expect(rows).toHaveCount(1) + await expect(rows.first()).toHaveAttribute("data-row-id", "1:*1000") + expect(writes).toEqual([]) +})