fix(ui): завершить локальную приёмку трафика и статистики
This commit is contained in:
1 parent
48b7a061c1
commit
165f40acd7
10 files changed
+122
-20
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 и сетевые операции. Исторические записи выше сохраняются как журнал промежуточных решений.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user