fix(ui): завершить локальную приёмку трафика и статистики

This commit is contained in:
Denozordec committed 2026-09-28 23:45:48 +07:00
1 parent 48b7a061c1
commit 165f40acd7
10 files changed
+122 -20

No files matched your search

+1 -1
View File
@@ -1215,7 +1215,7 @@ export default function TrafficPage() {
{liveBusy && <p role="status">Загрузка потоков…</p>}
{liveError && <Alert variant="destructive" role="alert"><AlertTitle>Потоки недоступны</AlertTitle><AlertDescription>{liveError}<Button size="sm" variant="outline" onClick={() => setFlowRetry(value => value + 1)}>Повторить загрузку потоков</Button></AlertDescription></Alert>}
{flowError && !liveError && (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
<div role="alert" className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{flowError}
</div>
)}
@@ -26,6 +26,7 @@ function SegmentedControl<T extends string>({
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
+2 -2
View File
@@ -25,7 +25,7 @@ export function DimensionSelect({
<label className="flex items-center gap-2">
{label ? <span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span> : null}
<Select value={value} onValueChange={(v) => onChange(String(v ?? value) as StatisticsSliceKind)}>
<SelectTrigger size="sm" className="min-w-40">
<SelectTrigger aria-label={label ?? "Измерение"} size="sm" className="min-w-40">
<SelectValue>{current?.label ?? "Измерение"}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
@@ -57,7 +57,7 @@ export function PivotDimSelect({
<label className="flex items-center gap-2">
<span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span>
<Select value={value} onValueChange={(v) => onChange(String(v ?? value) as StatisticsPivotDim)}>
<SelectTrigger size="sm" className="min-w-40">
<SelectTrigger aria-label={label ?? "Измерение"} size="sm" className="min-w-40">
<SelectValue>{current?.label ?? label}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
+12 -8
View File
@@ -1,12 +1,13 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { FormField, FormToggle } from "@/components/form-kit"
import { FormField } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Frame, FramePanel } from "@/components/reui/frame"
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
@@ -56,8 +57,10 @@ function FlowOverlaySheet({
const [tab, setTab] = useState("linux")
const [disableGreFastPath, setDisableGreFastPath] = useState(false)
useEffect(() => {
if (!open) return
const [resetContext, setResetContext] = useState({ open: false, jumpHosts })
if (resetContext.open !== open || resetContext.jumpHosts !== jumpHosts) {
setResetContext({ open, jumpHosts })
if (open) {
setResult(null)
setCopied(false)
setTab("linux")
@@ -66,7 +69,8 @@ function FlowOverlaySheet({
const nextId = first ? String(first.id) : ""
setServerId(nextId)
setEndpoint(first?.host ?? "")
}, [open, jumpHosts])
}
}
function handleServerChange(id: string) {
setServerId(id)
@@ -130,7 +134,7 @@ function FlowOverlaySheet({
value={serverId || undefined}
onValueChange={(v) => handleServerChange(String(v ?? ""))}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectTrigger aria-label="Jump-host" className="h-9 w-full min-w-0">
<SelectValue>{selectedLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="start" className="min-w-(--anchor-width)">
@@ -147,7 +151,7 @@ function FlowOverlaySheet({
required
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
>
<Input
<Input aria-label="Публичный IP или DNS jump-host"
className="font-mono"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
@@ -160,7 +164,7 @@ function FlowOverlaySheet({
hint="allow-fast-path=no на GRE этого JH. Inner YouTube попадёт в Traffic Flow, но вырастет CPU. По умолчанию выкл."
>
<div className="flex items-center gap-2">
<FormToggle checked={disableGreFastPath} onChange={setDisableGreFastPath} />
<Switch aria-label="Выключить FastPath на GRE" checked={disableGreFastPath} onCheckedChange={setDisableGreFastPath} />
<span className="text-sm text-muted-foreground">Выключить FastPath на GRE</span>
</div>
</FormField>
+6 -6
View File
@@ -80,17 +80,17 @@ flowchart LR
## Текущая точка продолжения
По журналу этапы 0–7 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 8 в работе, этапы 9–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этапы 9–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8](ux-ui-stage-8.md).
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md).
Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4П. В flow и остальных видах трафика добавлены отметки получения API/SSE; для серверного SSE отдельно показана метка обработки ответа сервером. Выполнена промежуточная приёмка 8.5; этап ещё открыт.
Этап 8 завершён блоком 8.13: периоды и URL статистики, достоверность REST/SSE, защита от гонок, свежесть и разрывы графиков, месячный отчёт с каталогом неактивных серверов, адаптивность и профильная клавиатурная приёмка. Повреждённые ответы статистики показывают ошибку с повтором. Точный состав проверок и ограничений сохранён в журнале.
Ближайший оставшийся объём:
1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Одиночные точки графика исправлены в 8.8; ожидание первого sample проверено в 8.9. Выбор месяца реализован в 8.10, каталог неактивных серверов — в 8.11. Подписи и выбранные состояния исправлены в 8.12; следующий шаг — оставшийся аудит доступности и итоговая регрессия.
2. Итоговая приёмка этапа 8: ReUI/accessibility checklist и регрессия; удалённые серверы остаются ограничением. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md).
3. После фиксации результата этапа 8 — этап 9.
1. Этап 9: инвентаризация WireGuard, IPsec, GRE, VXLAN, Firewall и Filters; схема «устройство → объект → форма → подтверждённый результат», карта API/прав и первый законченный блок.
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
## Порядок продолжения и журналирования
+10 -1
View File
@@ -24,13 +24,22 @@
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.12; подписи/состояния исправлены, далее итоговая регрессия |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Не начат |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.13 — локальная приёмка трафика и статистики (2026-09-28)
- Добавлены доступные имена полей панели подключения jump-host и измерений статистики, aria-pressed общего SegmentedControl и role=alert ошибки flow. Сохранены установленные ReUI/base-nova, Frame и общие компоненты; зависимости и сетевые операции не менялись.
- Клавиатурные сценарии проверяют Enter, открытие списка стрелкой, Escape, удержание Tab внутри панели, возврат фокуса и сброс формы при повторном открытии без запросов записи. Устранён прежний lint-дефект синхронного сброса формы в effect; сброс теперь привязан к изменению контекста открытия/каталога.
- Обнаружена и исправлена ошибка обработки повреждённого ответа HTTP 200 статистики: основной и pivot-ответы проверяются существующими схемами contracts. Вместо падения страницы появляется ошибка с повтором, недостоверная сводка скрыта. Оба случая проверены E2E без pageerror.
- Полный профильный запуск: 63 E2E прошли. После добавления валидации повторены все 15 сценариев статистики (включая 2 новых); после изменения сброса формы повторён сценарий панели. Итого проверены 65 различных сценариев трафика/статистики. Отдельно прошли 3 backend-switch E2E и 10 unit-тестов периодов/контекста/фильтров. Итоговые production build/TypeScript (34 страницы), lint изменённых файлов и diff-check прошли.
- Этап 8 закрыт в границах локальной приёмки HTTP/SSE-моками. Реальные RouterOS/IPFIX/JWT не проверены. Удалённые из каталога серверы отсутствуют в месячном выборе; экспорт не реализован. SSE поддерживает LF/CRLF, без автоматического переподключения после закрытия; одиночный CR не поддержан. Времена получения не означают время измерения на устройстве. Полный аудит скринридером, контраста, производительности и сквозная регрессия остаются этапом 12.
- Следующий шаг: этап 9 — инвентаризация WireGuard/IPsec/GRE/VXLAN/Firewall/Filters, контекста устройства и подтверждений операций; сохранить схему и выбрать первый законченный блок. План и итоговая матрица этапа 8 обновлены. На контрольной точке использовано 52% пятичасового и 39% недельного лимита. Локальный коммит без push.
## Блок 8.12 — доступные имена и выбранные состояния (2026-09-28)
- Получен актуальный ReUI get_audit_checklist. Поисковые поля трафика/потоков получили доступные имена. Карточки, разделы, источник flow, периоды и интерфейсы обозначают выбранное состояние через aria-pressed; затронутые native-кнопки получили type=button.
+18
View File
@@ -1,5 +1,7 @@
# Этап 8: трафик и статистика
Статус: завершён 2026-09-28 в границах локальной приёмки. Итоговая матрица 8.13 ниже уточняет и заменяет промежуточные точки продолжения.
Основа: установленные ReUI PRO/base-nova, Frame, KpiStatGrid, DataGrid, Filters и DateSelector. Каждый законченный блок — профильные проверки, журнал и локальный коммит без push.
| Блок | Результат | Приёмка |
@@ -56,3 +58,19 @@
Обновление 8.11 (2026-09-28): месячный режим выбирает сервер из каталога независимо от активности за 24 часа. 25 flow-E2E и дополнительная проверка пустого списка экспортёров прошли, как и lint/build. Удалённые из каталога серверы не перечисляются; далее доступность и итоговая регрессия этапа 8.
Обновление 8.12: получен checklist ReUI; имена поиска и aria-pressed исправлены. Клавиатурный E2E Enter/Space, lint/build прошли. Полный аудит Tab/фокуса/слоёв/статистики и итоговая регрессия ещё нужны; этап не закрыт.
## Итоговая локальная приёмка — блок 8.13 (2026-09-28)
| Проверка | Результат и границы |
| --- | --- |
| Профильная регрессия | 63 E2E в общем запуске; затем 15 сценариев статистики с 2 новыми проверками повреждённого HTTP 200. Всего 65 различных сценариев трафика/статистики проверены, включая 320/390/1440 px |
| Смена backend и контекст | Отдельно прошли 3 backend-switch E2E и 10 unit-тестов периодов, контекста и фильтров |
| Клавиатура и слои | Проверены Enter/Space, список измерений через стрелку, Escape, Tab внутри панели jump-host, возврат фокуса и сброс формы при повторном открытии без записи |
| Доступные состояния | Поиск, измерения и поля панели имеют имена; SegmentedControl и выборы трафика — aria-pressed; ошибка flow — role=alert |
| Достоверность статистики | Основной и pivot-ответы проверяются схемами contracts; повреждённый ответ даёт ошибку с повтором без падения и фиктивных KPI |
| ReUI checklist | Используются существующие Frame/DataGrid/Filters и base-nova; новые зависимости, копии registry-компонентов и изменения сетевых правил не добавлены. Профильная доступность проверена, полный аудит остаётся этапом 12 |
| Проверки кода | Production build/TypeScript: 34 страницы; lint изменённых файлов и diff-check прошли. После изменения сброса формы повторён E2E панели |
Границы приёмки: HTTP/SSE-моки не подтверждают реальную работу RouterOS/IPFIX/JWT. Серверы, удалённые из каталога, недоступны в месячном выборе; экспорт не реализован. После закрытия SSE автоматическое переподключение отсутствует; поддержаны LF/CRLF, одиночный CR не поддержан. Временные отметки получения не подменяют время измерения на устройстве. Полный аудит скринридером, контраста, производительности и сквозная регрессия предусмотрены этапом 12.
Следующий этап — 9: VPN и сетевые операции. Исторические записи выше сохраняются как журнал промежуточных решений.
+7 -2
View File
@@ -1,3 +1,4 @@
import { statisticsDtoSchema, statisticsPivotDtoSchema } from "@mmapp/contracts/statistics"
import type {
StatisticsDto,
StatisticsPivotDto,
@@ -23,7 +24,9 @@ export async function getStatistics(
if (query.service) params.set("service", query.service)
if (query.asn != null) params.set("asn", String(query.asn))
if (query.planes && query.planes !== "unique") params.set("planes", query.planes)
return requestJson<StatisticsDto>(baseUrl, `/api/statistics?${params.toString()}`)
const parsed = statisticsDtoSchema.safeParse(await requestJson<unknown>(baseUrl, `/api/statistics?${params.toString()}`))
if (!parsed.success) throw new Error("Некорректный ответ статистики")
return parsed.data
}
export async function getStatisticsPivot(
@@ -43,5 +46,7 @@ export async function getStatisticsPivot(
if (query.service) params.set("service", query.service)
if (query.asn != null) params.set("asn", String(query.asn))
if (query.planes && query.planes !== "unique") params.set("planes", query.planes)
return requestJson<StatisticsPivotDto>(baseUrl, `/api/statistics/pivot?${params.toString()}`)
const parsed = statisticsPivotDtoSchema.safeParse(await requestJson<unknown>(baseUrl, `/api/statistics/pivot?${params.toString()}`))
if (!parsed.success) throw new Error("Некорректный ответ сводной статистики")
return parsed.data
}
+36
View File
@@ -144,3 +144,39 @@ test('Статистика: история периода и выбранног
await expect(page.getByRole('button', { name: 'Применить срез', exact: true })).toBeVisible()
expect(writes).toEqual([])
})
test('Статистика: клавиатурный выбор среза и доступные состояния вида', async ({ page }) => {
await setup(page)
const criterion = page.getByRole('combobox', { name: 'Критерий', exact: true })
await criterion.focus()
await page.keyboard.press('ArrowDown')
await expect(page.getByRole('listbox')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('listbox')).toHaveCount(0)
await expect(criterion).toBeFocused()
await page.route('**/api/statistics/pivot?**', route => route.fulfill({ json: { rowDim: 'country', colDim: 'service', metric: 'bytes', columns: [], rows: [], otherBytes: 0 } }))
const pivot = page.getByRole('button', { name: 'Сводка', exact: true })
await pivot.focus()
await page.keyboard.press('Enter')
await expect(pivot).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByRole('button', { name: 'Разрез', exact: true })).toHaveAttribute('aria-pressed', 'false')
await expect(page.getByRole('combobox', { name: 'Строки', exact: true })).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Колонки', exact: true })).toBeVisible()
})
for (const pivot of [false, true]) {
test('Статистика: повреждённый успешный ответ не обрушает страницу ' + (pivot ? 'pivot' : 'основной'), async ({ page }) => {
await setup(page)
const crashes: string[] = []
page.on('pageerror', error => crashes.push(error.message))
await page.route(pivot ? '**/api/statistics/pivot?**' : '**/api/statistics?**', route => route.fulfill({ json: {} }))
if (pivot) await page.getByRole('button', { name: 'Сводка', exact: true }).click()
else await page.reload()
await expect(page.getByRole('alert').filter({ hasText: 'Ошибка загрузки' })).toContainText(pivot ? 'Некорректный ответ сводной статистики' : 'Некорректный ответ статистики')
await expect(page.getByRole('button', { name: 'Повторить загрузку', exact: true })).toBeVisible()
await expect(page.getByLabel('Сводка трафика')).toHaveCount(0)
expect(crashes).toHaveLength(0)
})
}
+29
View File
@@ -446,3 +446,32 @@ test('Flow: клавиатурный выбор сервера и месяца
await expect(month).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByLabel('Месяц отчёта (UTC)', { exact: true })).toBeVisible()
})
test('Flow: панель подключения удерживает Tab-фокус и закрывается Escape без записи', async ({ page }) => {
await setup(page)
const writes: string[] = []
page.on('request', r => { if (r.method() !== 'GET') writes.push(r.url()) })
const trigger = page.getByRole('button', { name: 'Подключить JH', exact: true })
await trigger.focus()
await page.keyboard.press('Enter')
const dialog = page.getByRole('dialog', { name: 'Подключить jump-host', exact: true })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('combobox', { name: 'Jump-host', exact: true })).toBeVisible()
const endpoint = dialog.getByRole('textbox', { name: 'Публичный IP или DNS jump-host', exact: true })
await endpoint.focus()
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab')
await expect.poll(() => dialog.evaluate(node => node.contains(document.activeElement))).toBe(true)
}
await expect(dialog.getByRole('switch', { name: 'Выключить FastPath на GRE' })).toBeVisible()
await endpoint.fill('changed.example.test')
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
await expect(endpoint).toHaveValue('127.0.0.1')
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
expect(writes).toHaveLength(0)
})