fix(ui): открыть крайние колонки сводной статистики

This commit is contained in:
Denozordec committed 2026-10-04 04:11:53 +07:00
1 parent 8d6d82082c
commit 488f2fe482
9 files changed
+100 -6

No files matched your search

+12 -2
View File
@@ -1,6 +1,6 @@
"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, useEffect, type ReactNode } from "react"
import {
@@ -31,6 +31,7 @@ interface CompactDataGridProps<T extends { id: string }> {
data: T[]
columns: CompactDataGridColumn<T>[]
isLoading?: boolean
horizontalScroll?: boolean
emptyTitle?: string
emptyDescription?: string
onRowClick?: (row: T) => void
@@ -41,6 +42,7 @@ function CompactDataGrid<T extends { id: string }>({
data,
columns,
isLoading,
horizontalScroll = false,
emptyTitle = "Нет записей",
emptyDescription,
onRowClick,
@@ -118,7 +120,15 @@ function CompactDataGrid<T extends { id: string }>({
className="border-0 py-10"
/>
}
/>
>
{horizontalScroll ? (
<DataGridContainer border={false} className="w-full rounded-none border-0">
<DataGridScrollArea orientation="horizontal">
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
) : undefined}
</DataGridShell>
)
}
@@ -47,20 +47,24 @@ export function StatisticsPivotGrid({
accessorKey: `c:${col.id}` as const,
cell: (row: PivotGridRow) => {
const value = Number(row[`c:${col.id}`] ?? 0)
const label = data.metric === "packets" ? value.toLocaleString("ru-RU") : formatBytes(value)
if (col.id === "__other__" || row.id === "__other__") {
return <span className="tabular-nums text-muted-foreground">{label}</span>
}
return (
<button
type="button"
aria-label={`${row.label}, ${col.label}: ${label}. Показать срез`}
className={cn(
"tabular-nums text-left hover:underline",
col.id === "__other__" || row.id === "__other__" ? "text-muted-foreground" : "",
)}
onClick={(e) => {
e.stopPropagation()
if (row.id === "__other__" || col.id === "__other__") return
onCellClick?.(row.id, col.id)
}}
>
{data.metric === "packets" ? value.toLocaleString("ru-RU") : formatBytes(value)}
{label}
</button>
)
},
@@ -80,6 +84,7 @@ export function StatisticsPivotGrid({
return (
<CompactDataGrid
data={rows}
horizontalScroll
columns={columns}
isLoading={isLoading}
emptyTitle="Нет данных сводной"
+2
View File
@@ -62,3 +62,5 @@
Статический аудит исходников на c4110fa: schedulerGridRows строится из SCHEDULER_JOB_KEYS (11), поэтому отсутствие client pagination не означает1000 строк. TrafficFlowsDataGrid получает analytics.conversationsList, а buildFlowAnalyticsUncached задаёт top=min(50,max(10,settings.topN)) и slice(0,top); page/limit в этом пути нет. Старый /traffic/flows listFlowTalkers — другой источник с settings.topN, его нельзя смешивать с текущей таблицей analytics. Uptime speed history GET /uptime/speed-test/runs использует DB limit200; frontend локальный run список ограничен20. Эти ограничения установлены чтением кода, не нагрузочным испытанием PG. Остальные строки карты требуют проверки по своим потребителям;25 manualPagination:true файлов не означают25 одинаковых дефектов.
Statistics pivot в backend/src/services/statistics-aggregate.ts ограничен PIVOT_ROW_CAP=50 и PIVOT_COL_CAP=15; при превышении добавляются агрегаты __other__. Проверять фактическую границу51×16, прокрутку и переходы к срезам, а не произвольную матрицу1000×1000. Это статический аудит источника; browser evidence широкой pivot ещё требуется.
12.3.13: pivot51×16/17311 JSON bytes принята в описанных локальных границах.50 rows/2574 nodes desktop и2313 mobile; крайние колонки доступны через штатный DataGridScrollArea, агрегаты Other без ложных действий. Sort/page2/два измерения/Back/reload/320px/axe проверены. Фактический cap backend исключает потребность в произвольной матрице1000×1000; реальная скорость PG не измерялась.
+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, затем history200/probes-schedule и итоговая матрица;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, затем probes-schedule и итоговая матрица;12.4/12.5 ещё не завершены. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+11 -1
View File
@@ -29,13 +29,23 @@
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 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 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;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 wide pivot принята локально |
## 12.3.13 — широкая pivot и доступные действия ячеек (2026-10-04)
Профиль фактического backend cap51×16 (50 строк/15 колонок плюс Other) воспроизвёл отсутствие горизонтальной области прокрутки. CompactDataGrid получает отдельную horizontalScroll опцию со штатными DataGridContainer/DataGridScrollArea/DataGridTable; включена только у StatisticsPivotGrid. Pagination остаётся за пределами ScrollArea через DataGridShell. Primitive/theme не менялись. Агрегаты Other теперь текст, не кнопки без действия; имя обычной кнопки содержит оба измерения, значение и действие.
Fixture17311 JSON bytes,50 строк в DOM; после исправления2574 nodes/1017ms dev ready на1440 и2313/762ms на320. Это единичные dev наблюдения, не SLA. Проверены page2 с Other, сортировка всего51-row набора, scrollLeft до крайних колонок, отсутствие page overflow, Enter→userId50/service15, подтверждённый GET с двумя параметрами, Back/reload, отсутствие POST. В отчёте нет обещания сохранения page при refresh: отдельного refresh у статистики нет, reload восстанавливает URL и начальную страницу.
24 browser statistics/large/migrated-tables прошли; финальные2 pivot повторены после типизации injected axe. Tagged WCAG axe области grid прошёл на320/1440; снимки просмотрены. Typecheck/ESLint и production frontend/contracts34 страницы/backend build прошли. Main/context отчёты12.4.1/12.4.2 относятся к предыдущему production HEAD; эта локальная delta проверена профильными24+2, общая регрессия после неё не повторялась.
Следующий блок12.3.14: проверить200 speed-test history по реальному backend cap, ограничить строки DOM и сохранить сортировку/страницы/320px. Затем probes-schedule и оставшиеся специализированные grids;12.4 итоговая матрица/12.5 внешняя приёмка ещё не завершены. Локальный коммит без push.
## 12.4.2 — context-регрессия и чистые проверки источников (2026-10-04)
Новая playwright.ux-context.config.ts переиспользует основной список23 testIgnore как testMatch, добавляет backend-switch.e2e.ts и запускает24 файла последовательно на3101 с NEXT_PUBLIC_BACKEND_URL=local. Индивидуальные конфигурации сохранены.205 passed/0 failed/0 skipped/0 flaky,7.0 минут,Edge/workers1/retries0. Проверены поздние чтения/записи при backend/demo/ABA, scoped unknown checkpoints, health, фоновые jobs/экспорт и смена контекста сетевых объектов. Отчёт: [context-regression-2026-10-04.json](ux-ui/acceptance/context-regression-2026-10-04.json).
+10
View File
@@ -239,3 +239,13 @@ Typecheck и ESLint двух изменённых файлов прошли. Pro
28 unit-проверок прошли:8 lib dashboard/Evolution профилей и4 чистых/DI backend профиля traffic-rate/BGP parser/OSPF values/Internet path snapshot. Реальная БД, migrations и сетевое применение не запускались. Typecheck и ESLint новой конфигурации прошли. Production код с момента c4110fa не менялся; frontend/contracts build34 страницы на нём принят.12.4 ещё требует итоговой матрицы и backend build;12.3 — оставшихся специализированных границ. Внешние ограничения12.5 остаются.
Следующий блок12.3.13: browser профиль широкой pivot51×16 (50/15 cap + Other), глобальная сортировка/последняя страница/refresh/320px/переход к двум измерениям и возврат. Затем speed history200 и probes-schedule по фактическому API. Коммит локальный, без push.
## 12.3.13 — широкая pivot и доступные действия ячеек (2026-10-04)
Профиль фактического backend cap51×16 (50 строк/15 колонок плюс Other) воспроизвёл отсутствие горизонтальной области прокрутки. CompactDataGrid получает отдельную horizontalScroll опцию со штатными DataGridContainer/DataGridScrollArea/DataGridTable; включена только у StatisticsPivotGrid. Pagination остаётся за пределами ScrollArea через DataGridShell. Primitive/theme не менялись. Агрегаты Other теперь текст, не кнопки без действия; имя обычной кнопки содержит оба измерения, значение и действие.
Fixture17311 JSON bytes,50 строк в DOM; после исправления2574 nodes/1017ms dev ready на1440 и2313/762ms на320. Это единичные dev наблюдения, не SLA. Проверены page2 с Other, сортировка всего51-row набора, scrollLeft до крайних колонок, отсутствие page overflow, Enter→userId50/service15, подтверждённый GET с двумя параметрами, Back/reload, отсутствие POST. В отчёте нет обещания сохранения page при refresh: отдельного refresh у статистики нет, reload восстанавливает URL и начальную страницу.
24 browser statistics/large/migrated-tables прошли; финальные2 pivot повторены после типизации injected axe. Tagged WCAG axe области grid прошёл на320/1440; снимки просмотрены. Typecheck/ESLint и production frontend/contracts34 страницы/backend build прошли. Main/context отчёты12.4.1/12.4.2 относятся к предыдущему production HEAD; эта локальная delta проверена профильными24+2, общая регрессия после неё не повторялась.
Следующий блок12.3.14: проверить200 speed-test history по реальному backend cap, ограничить строки DOM и сохранить сортировку/страницы/320px. Затем probes-schedule и оставшиеся специализированные grids;12.4 итоговая матрица/12.5 внешняя приёмка ещё не завершены. Локальный коммит без push.
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test"
import axe from "axe-core"
import { mkdirSync } from "node:fs"
import { statisticsDtoSchema, statisticsPivotDtoSchema } from "../../packages/contracts/src/statistics"
for (const width of [1440, 320]) test("Pivot51×16: границы, сортировка, прокрутка и срез/" + width, async ({ page }, info) => {
await page.setViewportSize({ width, height: 1000 })
const columns = Array.from({ length: 16 }, (_, i) => ({ id: i === 15 ? "__other__" : "service-" + (i + 1), label: i === 15 ? "Прочие сервисы" : "Сервис " + (i + 1), total: 0 }))
const rows = Array.from({ length: 51 }, (_, i) => ({ id: i === 50 ? "__other__" : String(i + 1), label: i === 50 ? "Прочие клиенты" : "Клиент " + String(i + 1).padStart(4, "0"), total: 0, cells: Object.fromEntries(columns.map((c, j) => [c.id, i === 0 && j === 0 ? 0 : (i + 1) * (j + 1)])) }))
for (const row of rows) row.total = Object.values(row.cells).reduce((sum, n) => sum + n, 0)
for (const col of columns) col.total = rows.reduce((sum, row) => sum + row.cells[col.id], 0)
const pivot = statisticsPivotDtoSchema.parse({ rowDim: "user", colDim: "service", metric: "bytes", rows, columns, otherBytes: rows.at(-1)!.total + columns.at(-1)!.total - rows.at(-1)!.cells.__other__ })
const reads: URL[] = [], writes: string[] = []
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const url = new URL(r.request().url()); reads.push(url)
if (r.request().method() !== "GET") writes.push(url.pathname)
return r.fulfill({ json: url.pathname === "/api/statistics/pivot" ? pivot : url.pathname === "/api/statistics" ? statisticsDtoSchema.parse({ from: url.searchParams.get("from"), to: url.searchParams.get("to"), grain: "day", kpis: { bytes: rows.reduce((sum, row) => sum + row.total, 0), packets: 0, avgBps: 0, users: 51, servers: 0, ifaces: 0, topCountry: "", topService: "" }, series: [], users: [], servers: [], interfaces: [], countries: [], services: [], asns: [] }) : url.pathname === "/api/auth/config" ? { required: false } : url.pathname === "/api/servers" ? [] : {} })
})
const target = "/statistics?from=2026-09-01&to=2026-09-03&view=pivot&pivotRow=user&pivotCol=service"
const start = Date.now(); await page.goto(target)
const rendered = page.locator("tr[data-row-id]")
await expect(rendered).toHaveCount(50)
const sample = { fixtureRows: 51, fixtureColumns: 16, fixtureBytes: Buffer.byteLength(JSON.stringify(pivot)), renderedRows: await rendered.count(), domNodes: await page.locator("*").count(), readyMs: Date.now() - start, width }
console.log(JSON.stringify(sample)); await info.attach("wide-pivot-metrics", { body: JSON.stringify(sample), contentType: "application/json" })
expect(sample.domNodes).toBeLessThan(10000)
await expect(page.getByText("1–50 из 51", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Следующая страница", exact: true }).click()
await expect(rendered).toHaveCount(1); await expect(rendered.first()).toContainText("Прочие клиенты")
await page.getByRole("button", { name: "Предыдущая страница", exact: true }).click()
await page.getByRole("table").getByRole("button", { name: "Итого", exact: true }).click()
await expect(rendered.first()).toHaveAttribute("data-row-id", "__other__")
await expect(rendered.nth(1)).toHaveAttribute("data-row-id", "50")
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 })
await expect.poll(() => scroll.evaluate(el => el.scrollLeft)).toBeGreaterThan(0)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await expect(rendered.first().getByRole("button")).toHaveCount(0)
await expect(rendered.nth(1).getByRole("cell").nth(16).getByRole("button")).toHaveCount(0)
await page.addScriptTag({ content: axe.source })
const violations = await page.evaluate(async () => (await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[data-slot="data-grid"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } })).violations)
expect(violations).toEqual([])
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
mkdirSync("docs/ux-ui/screens", { recursive: true })
await page.screenshot({ path: "docs/ux-ui/screens/wide-statistics-pivot-" + width + ".png", animations: "disabled" })
const lastCell = rendered.nth(1).getByRole("cell").nth(15).getByRole("button")
await lastCell.focus(); await page.keyboard.press("Enter")
await expect(page).toHaveURL(/view=explore/)
const selected = new URL(page.url()); expect(selected.searchParams.get("userId")).toBe("50"); expect(selected.searchParams.get("service")).toBe("service-15")
expect(reads.some(url => url.pathname === "/api/statistics" && url.searchParams.get("userId") === "50" && url.searchParams.get("service") === "service-15")).toBe(true)
await page.goBack(); await expect(rendered).toHaveCount(50)
await page.reload(); await expect(rendered).toHaveCount(50)
await expect(page.getByRole("button", { name: "Следующая страница", exact: true })).toBeVisible()
expect(writes).toEqual([])
})