fix(ui): завершить локальную приёмку трафика и статистики
This commit is contained in:
1 parent
48b7a061c1
commit
165f40acd7
10 files changed
+130
-28
No files matched your search
@@ -1215,7 +1215,7 @@ export default function TrafficPage() {
|
|||||||
{liveBusy && <p role="status">Загрузка потоков…</p>}
|
{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>}
|
{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 && (
|
{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}
|
{flowError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function SegmentedControl<T extends string>({
|
|||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={value === option.value}
|
||||||
onClick={() => onChange(option.value)}
|
onClick={() => onChange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
"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 className="flex items-center gap-2">
|
||||||
{label ? <span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span> : null}
|
{label ? <span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span> : null}
|
||||||
<Select value={value} onValueChange={(v) => onChange(String(v ?? value) as StatisticsSliceKind)}>
|
<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>
|
<SelectValue>{current?.label ?? "Измерение"}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="start">
|
<SelectContent align="start">
|
||||||
@@ -57,7 +57,7 @@ export function PivotDimSelect({
|
|||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span>
|
<span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span>
|
||||||
<Select value={value} onValueChange={(v) => onChange(String(v ?? value) as StatisticsPivotDim)}>
|
<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>
|
<SelectValue>{current?.label ?? label}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="start">
|
<SelectContent align="start">
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { toast } from "sonner"
|
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 { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
|
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import {
|
import {
|
||||||
@@ -56,17 +57,20 @@ function FlowOverlaySheet({
|
|||||||
const [tab, setTab] = useState("linux")
|
const [tab, setTab] = useState("linux")
|
||||||
const [disableGreFastPath, setDisableGreFastPath] = useState(false)
|
const [disableGreFastPath, setDisableGreFastPath] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const [resetContext, setResetContext] = useState({ open: false, jumpHosts })
|
||||||
if (!open) return
|
if (resetContext.open !== open || resetContext.jumpHosts !== jumpHosts) {
|
||||||
setResult(null)
|
setResetContext({ open, jumpHosts })
|
||||||
setCopied(false)
|
if (open) {
|
||||||
setTab("linux")
|
setResult(null)
|
||||||
setDisableGreFastPath(false)
|
setCopied(false)
|
||||||
const first = jumpHosts[0]
|
setTab("linux")
|
||||||
const nextId = first ? String(first.id) : ""
|
setDisableGreFastPath(false)
|
||||||
setServerId(nextId)
|
const first = jumpHosts[0]
|
||||||
setEndpoint(first?.host ?? "")
|
const nextId = first ? String(first.id) : ""
|
||||||
}, [open, jumpHosts])
|
setServerId(nextId)
|
||||||
|
setEndpoint(first?.host ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleServerChange(id: string) {
|
function handleServerChange(id: string) {
|
||||||
setServerId(id)
|
setServerId(id)
|
||||||
@@ -130,7 +134,7 @@ function FlowOverlaySheet({
|
|||||||
value={serverId || undefined}
|
value={serverId || undefined}
|
||||||
onValueChange={(v) => handleServerChange(String(v ?? ""))}
|
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>
|
<SelectValue>{selectedLabel}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="start" className="min-w-(--anchor-width)">
|
<SelectContent align="start" className="min-w-(--anchor-width)">
|
||||||
@@ -147,7 +151,7 @@ function FlowOverlaySheet({
|
|||||||
required
|
required
|
||||||
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
|
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
|
||||||
>
|
>
|
||||||
<Input
|
<Input aria-label="Публичный IP или DNS jump-host"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={endpoint}
|
value={endpoint}
|
||||||
onChange={(e) => setEndpoint(e.target.value)}
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
@@ -160,7 +164,7 @@ function FlowOverlaySheet({
|
|||||||
hint="allow-fast-path=no на GRE этого JH. Inner YouTube попадёт в Traffic Flow, но вырастет CPU. По умолчанию выкл."
|
hint="allow-fast-path=no на GRE этого JH. Inner YouTube попадёт в Traffic Flow, но вырастет CPU. По умолчанию выкл."
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-sm text-muted-foreground">Выключить FastPath на GRE</span>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</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; следующий шаг — оставшийся аудит доступности и итоговая регрессия.
|
1. Этап 9: инвентаризация WireGuard, IPsec, GRE, VXLAN, Firewall и Filters; схема «устройство → объект → форма → подтверждённый результат», карта API/прав и первый законченный блок.
|
||||||
2. Итоговая приёмка этапа 8: ReUI/accessibility checklist и регрессия; удалённые серверы остаются ограничением. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md).
|
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||||
3. После фиксации результата этапа 8 — этап 9.
|
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||||
|
|
||||||
## Порядок продолжения и журналирования
|
## Порядок продолжения и журналирования
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -24,13 +24,22 @@
|
|||||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.12; подписи/состояния исправлены, далее итоговая регрессия |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Не начат |
|
| 9 | VPN, Firewall и Filters | Не начат |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 8.12 — доступные имена и выбранные состояния (2026-09-28)
|
||||||
|
|
||||||
- Получен актуальный ReUI get_audit_checklist. Поисковые поля трафика/потоков получили доступные имена. Карточки, разделы, источник flow, периоды и интерфейсы обозначают выбранное состояние через aria-pressed; затронутые native-кнопки получили type=button.
|
- Получен актуальный ReUI get_audit_checklist. Поисковые поля трафика/потоков получили доступные имена. Карточки, разделы, источник flow, периоды и интерфейсы обозначают выбранное состояние через aria-pressed; затронутые native-кнопки получили type=button.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Этап 8: трафик и статистика
|
# Этап 8: трафик и статистика
|
||||||
|
|
||||||
|
Статус: завершён 2026-09-28 в границах локальной приёмки. Итоговая матрица 8.13 ниже уточняет и заменяет промежуточные точки продолжения.
|
||||||
|
|
||||||
Основа: установленные ReUI PRO/base-nova, Frame, KpiStatGrid, DataGrid, Filters и DateSelector. Каждый законченный блок — профильные проверки, журнал и локальный коммит без push.
|
Основа: установленные 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.11 (2026-09-28): месячный режим выбирает сервер из каталога независимо от активности за 24 часа. 25 flow-E2E и дополнительная проверка пустого списка экспортёров прошли, как и lint/build. Удалённые из каталога серверы не перечисляются; далее доступность и итоговая регрессия этапа 8.
|
||||||
|
|
||||||
Обновление 8.12: получен checklist ReUI; имена поиска и aria-pressed исправлены. Клавиатурный E2E Enter/Space, lint/build прошли. Полный аудит Tab/фокуса/слоёв/статистики и итоговая регрессия ещё нужны; этап не закрыт.
|
Обновление 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 {
|
import type {
|
||||||
StatisticsDto,
|
StatisticsDto,
|
||||||
StatisticsPivotDto,
|
StatisticsPivotDto,
|
||||||
@@ -23,7 +24,9 @@ export async function getStatistics(
|
|||||||
if (query.service) params.set("service", query.service)
|
if (query.service) params.set("service", query.service)
|
||||||
if (query.asn != null) params.set("asn", String(query.asn))
|
if (query.asn != null) params.set("asn", String(query.asn))
|
||||||
if (query.planes && query.planes !== "unique") params.set("planes", query.planes)
|
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(
|
export async function getStatisticsPivot(
|
||||||
@@ -43,5 +46,7 @@ export async function getStatisticsPivot(
|
|||||||
if (query.service) params.set("service", query.service)
|
if (query.service) params.set("service", query.service)
|
||||||
if (query.asn != null) params.set("asn", String(query.asn))
|
if (query.asn != null) params.set("asn", String(query.asn))
|
||||||
if (query.planes && query.planes !== "unique") params.set("planes", query.planes)
|
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()
|
await expect(page.getByRole('button', { name: 'Применить срез', exact: true })).toBeVisible()
|
||||||
expect(writes).toEqual([])
|
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(month).toHaveAttribute('aria-pressed', 'true')
|
||||||
await expect(page.getByLabel('Месяц отчёта (UTC)', { exact: true })).toBeVisible()
|
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