From 7dd377d7c3f0561aa36369899927e9e695a57b05 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 28 Sep 2026 16:54:17 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B2=D1=8B=D0=B1=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=B5=D1=81=D1=8F=D1=86=20=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D1=91=D1=82=D0=B0=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/traffic/page.tsx | 15 ++++--- components/traffic/flow-analytics-panel.tsx | 15 ++++++- docs/ux-ui-plan.md | 4 +- docs/ux-ui-progress.md | 9 ++++- docs/ux-ui-stage-8.md | 2 + tests/e2e/traffic-flow.spec.ts | 44 +++++++++++++++++++-- 6 files changed, 76 insertions(+), 13 deletions(-) diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 2ba2b0a3..54816d93 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -804,6 +804,8 @@ export default function TrafficPage() { const flowStats = currentFlows?.stats ?? null const flowExporters = useMemo(() => currentFlows?.exporters ?? [], [currentFlows]) const flowClients = useMemo(() => currentFlows?.clients ?? [], [currentFlows]) + const [flowMonth, setFlowMonth] = useState(() => new Date().toISOString().slice(0, 7)) + const flowMonthValid = /^(?!0000)\d{4}-(0[1-9]|1[0-2])$/.test(flowMonth) && flowMonth <= new Date().toISOString().slice(0, 7) const [analyticsRetry, setAnalyticsRetry] = useState(0) const [analyticsResult, setAnalyticsResult] = useState<{ key: string; receivedAt?: number; data?: FlowAnalyticsDto; monthly?: FlowMonthlyDto; error?: string } | null>(null) const flowIfaceOwner = JSON.stringify([isLive, backendUrl, groupMode, flowScope, selectedId]) @@ -894,14 +896,14 @@ export default function TrafficPage() { return () => { cancelled = true } }, [isLive, effectiveMode, backendUrl, range, flowScope, flowKey]) - const analyticsKey = JSON.stringify([flowKey, selectedId, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, analyticsRetry]) + const analyticsKey = JSON.stringify([flowKey, selectedId, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, analyticsRetry, range === "30d" ? flowMonth : null]) const currentAnalytics = analyticsResult?.key === analyticsKey ? analyticsResult : null - const analyticsLoading = isLive && effectiveMode === "flows" && Boolean(selectedId) && !currentAnalytics + const analyticsLoading = isLive && effectiveMode === "flows" && Boolean(selectedId) && (range !== "30d" || flowMonthValid) && !currentAnalytics useEffect(() => { - if (!isLive || effectiveMode !== "flows" || !selectedId) return + if (!isLive || effectiveMode !== "flows" || !selectedId || (range === "30d" && !flowMonthValid)) return let cancelled = false const request = range === "30d" - ? getFlowMonthly(backendUrl, { month: new Date().toISOString().slice(0, 7), serverId: flowScope === "servers" ? selectedId : undefined }).then(monthly => ({ monthly })) + ? getFlowMonthly(backendUrl, { month: flowMonth, serverId: flowScope === "servers" ? selectedId : undefined }).then(monthly => ({ monthly })) : getFlowAnalytics(backendUrl, { range, serverId: flowScope === "servers" ? selectedId : undefined, userId: flowScope === "users" ? selectedId : undefined, @@ -913,7 +915,7 @@ export default function TrafficPage() { if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" }) }) return () => { cancelled = true } - }, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey]) + }, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey, flowMonth, flowMonthValid]) useEffect(() => { if (!isLive) return @@ -1285,6 +1287,9 @@ export default function TrafficPage() { analytics={displayedFlow} monthly={currentAnalytics?.monthly} monthlyAvailable={flowScope === "servers"} + month={flowMonth} + onMonth={setFlowMonth} + monthValid={flowMonthValid} range={range} onRange={(r) => setRange(r as Range)} selectedIface={flowIface} diff --git a/components/traffic/flow-analytics-panel.tsx b/components/traffic/flow-analytics-panel.tsx index 158748b3..df71f50d 100644 --- a/components/traffic/flow-analytics-panel.tsx +++ b/components/traffic/flow-analytics-panel.tsx @@ -12,6 +12,7 @@ import { TrafficMiniChart } from "@/components/reui-kit/traffic-mini-chart" import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Switch } from "@/components/ui/switch" +import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { @@ -44,7 +45,7 @@ const RANGE_LABELS: Record = { "1h": "1ч", "4h": "4ч", "24h": "24ч", - "30d": "Текущий месяц", + "30d": "Месяц", } @@ -305,6 +306,9 @@ export function FlowAnalyticsDetail({ analytics, monthly, monthlyAvailable = true, + month, + onMonth, + monthValid, range, onRange, selectedIface, @@ -322,6 +326,9 @@ export function FlowAnalyticsDetail({ analytics: FlowAnalyticsDto | null monthly?: FlowMonthlyDto monthlyAvailable?: boolean + month: string + onMonth: (value: string) => void + monthValid: boolean range: string onRange: (r: string) => void selectedIface: string @@ -407,6 +414,7 @@ export function FlowAnalyticsDetail({ key={r} type="button" onClick={() => onRange(r)} + aria-pressed={range === r} className={cn( "h-7 px-2 text-xs rounded border transition-colors", range === r @@ -421,6 +429,11 @@ export function FlowAnalyticsDetail({ + {range === "30d" &&
+ + onMonth(event.target.value)} aria-invalid={!monthValid} aria-describedby={!monthValid ? "flow-report-month-error" : undefined} /> + {!monthValid && } +
} {range === "30d" &&

Календарный месяц по UTC. Фильтры интерфейса, дублей, overlay и mesh к этому отчёту не применяются.

} {monthly && range === "30d" &&

Период: {monthly.month} (UTC) · Объём: {formatBytes(monthly.bytes)}

diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 96a730a7..75c45b0a 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -88,8 +88,8 @@ flowchart LR Ближайший оставшийся объём: -1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Одиночные точки графика исправлены в 8.8; ожидание первого sample проверено в 8.9. Следующий шаг — месячный отчёт и доступность. -2. Итоговая приёмка этапа 8: решение по архивным серверам/месяцам, ReUI/accessibility checklist. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md). +1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Одиночные точки графика исправлены в 8.8; ожидание первого sample проверено в 8.9. Выбор месяца реализован в 8.10; следующий шаг — архивные серверы и доступность. +2. Итоговая приёмка этапа 8: выбор серверов без активности за 24 часа, ReUI/accessibility checklist. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md). 3. После фиксации результата этапа 8 — этап 9. ## Порядок продолжения и журналирования diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 7e11d823..a8843a83 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -24,13 +24,20 @@ | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | -| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.9; ожидание первого SSE проверено, далее месячный отчёт и доступность | +| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.10; выбор месяца готов, далее архивные серверы и доступность | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.10 — выбор месяца flow-отчёта (2026-09-28) + +- Месячный отчёт использует выбранный календарный месяц UTC через существующий GET /api/traffic/flow/monthly. Доступны текущий и прошлые месяцы; пустые, некорректные и будущие значения блокируют запрос и показывают связанную с полем ошибку. Текущий месяц остаётся начальным значением. +- Месяц входит в ключ аналитики, старый отчёт скрыт при смене/невалидном вводе; поздний ответ предыдущего месяца не применяется. API и правила сетевых операций не менялись. Кнопка периода переименована в «Месяц»; кнопки периодов получили aria-pressed, поле — Label/aria-invalid/aria-describedby через существующий Input. +- 23 прежних flow-E2E прошли, включая адаптивность 320/390/1440. Новый сценарий смены 2026-08→2026-07, позднего ответа, пустого/будущего месяца и восстановления прошёл после уточнения селектора alert (Next.js добавляет свой announcer). Проверены программный фокус и aria-pressed; это не полный клавиатурный аудит. Итоговые lint, diff-check и production build/TypeScript прошли (34 страницы). +- Следующий шаг: выбор серверов без активности в последние 24 часа для месячного отчёта; затем оставшийся ReUI/accessibility checklist и итоговая регрессия. Пока отчёт доступен только для серверов текущего списка экспортёров; исторические удалённые серверы отдельно не перечисляются. Реальный RouterOS/IPFIX/JWT не проверен. Использовано 52% пятичасового и 24% недельного лимита. Локальный коммит без push. + ## Блок 8.9 — ожидание первого SSE sample (2026-09-28) - Управляемый ReadableStream умеет открываться без первого sample. Добавлены flow/серверные сценарии: до первого сообщения нет фиктивной live-метки или времени live-измерения; отображается подтверждённая REST-история. diff --git a/docs/ux-ui-stage-8.md b/docs/ux-ui-stage-8.md index b6056893..d483337c 100644 --- a/docs/ux-ui-stage-8.md +++ b/docs/ux-ui-stage-8.md @@ -50,3 +50,5 @@ Обновление 8.8 (2026-09-28): одиночные точки RX/TX, включая нули между пропусками, получили маркеры. Итоговые 10 тестов деталей и build/lint прошли; перед финальным подключением TX проверены 38 сценариев потребителей. Далее — первый SSE sample и оставшаяся приёмка. Обновление 8.9 (2026-09-28): проверено ожидание первого sample в открытом SSE для flow и сервера, heartbeat без sample, таймаут и переход с истории на live. 28 профильных E2E, lint/TypeScript прошли; production-код не менялся. Далее — решение по месячному отчёту и доступность. + +Обновление 8.10 (2026-09-28): добавлен выбор прошлого/текущего месяца UTC; валидация не отправляет пустые/будущие значения, ключ запроса защищает от позднего отчёта. 23 прежних и 1 новый flow-E2E прошли, lint/build чистые. Ограничение списка серверов по активности 24 часа остаётся следующим шагом; полный аудит доступности ещё не завершён. diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts index a9caea0d..7b783315 100644 --- a/tests/e2e/traffic-flow.spec.ts +++ b/tests/e2e/traffic-flow.spec.ts @@ -9,6 +9,7 @@ async function setup(page: Page) { await page.route('**/health', r => r.fulfill({ json: { ok: true } })) await page.route('**/api/**', async route => { const url = new URL(route.request().url()), path = url.pathname, range = url.searchParams.get('range') + if (path === '/api/traffic/flow/monthly' && control.hold === url.searchParams.get('month')) await gate if (path === '/api/traffic/flow/monthly') return route.fulfill({ json: { month: url.searchParams.get('month'), bytes: 1500, countries: [{ id: 'RU', label: 'Россия', bytes: 1500, packets: 1, bps: 0, percent: 100 }], services: [], asns: [] } }) if (path === '/api/traffic/flow/analytics') { if (control.hold === range || (control.holdIface && url.searchParams.get('iface') === control.holdIface)) await gate @@ -85,7 +86,7 @@ test('Flow: завершённый SSE не помечает последние test('Flow: месяц показывает только доступные агрегаты и не применяется к клиенту', async ({ page }) => { await setup(page) const monthlyRequest = page.waitForRequest(r => r.url().includes('/flow/monthly?')) - await page.getByRole('button', { name: 'Текущий месяц', exact: true }).click() + await page.getByRole('button', { name: 'Месяц', exact: true }).click() const url = new URL((await monthlyRequest).url()) expect(url.searchParams.get('serverId')).toBe('1') expect(url.searchParams.get('month')).toMatch(/^\d{4}-\d{2}$/) @@ -103,7 +104,7 @@ test('Flow: месяц показывает только доступные аг expect(new URL((await clientRequest).url()).searchParams.get('range')).toBe('1h') await expect(page.getByLabel('Скорость потоков')).toBeVisible() await expect(report).toHaveCount(0) - await expect(page.getByRole('button', { name: 'Текущий месяц', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Месяц', exact: true })).toHaveCount(0) await expect(page.getByRole('switch', { name: 'Без дублей' })).toBeEnabled() }) @@ -113,10 +114,10 @@ for (const width of [320, 390, 1440]) { await setup(page) for (const monthly of [false, true]) { if (monthly) { - await page.getByRole('button', { name: 'Текущий месяц', exact: true }).click() + await page.getByRole('button', { name: 'Месяц', exact: true }).click() await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toBeVisible() } - const button = page.getByRole('button', { name: 'Текущий месяц', exact: true }) + const button = page.getByRole('button', { name: 'Месяц', exact: true }) const box = await button.boundingBox() expect(box).not.toBeNull() expect(box!.x).toBeGreaterThanOrEqual(0) @@ -365,3 +366,38 @@ test('Flow: ожидание первого sample использует исто await expect(receipt).toContainText('Получено (live-поток)') await expect(receipt.locator('time')).not.toHaveAttribute('datetime', stamp!) }) + + +test('Flow: выбор месяца игнорирует поздний отчёт и блокирует пустое значение', async ({ page }) => { + const control = await setup(page) + await page.getByRole('button', { name: 'Месяц', exact: true }).click() + const field = page.getByLabel('Месяц отчёта (UTC)', { exact: true }) + const report = page.getByRole('region', { name: 'Месячный отчёт' }) + await expect(report).toBeVisible() + control.hold = '2026-08' + const old = page.waitForRequest(r => r.url().includes('/flow/monthly?') && r.url().includes('month=2026-08')) + await field.fill('2026-08') + await old + await expect(report).toHaveCount(0) + await field.fill('2026-07') + await expect(report).toContainText('2026-07 (UTC)') + const late = page.waitForResponse(r => r.url().includes('/flow/monthly?') && r.url().includes('month=2026-08')) + control.release() + await late + await expect(report).toContainText('2026-07 (UTC)') + const requests: string[] = [] + page.on('request', r => { if (r.url().includes('/flow/monthly?')) requests.push(r.url()) }) + await field.fill('') + await expect(field).toHaveAttribute('aria-invalid', 'true') + await expect(page.getByRole('alert').filter({ hasText: 'Выберите существующий месяц' })).toBeVisible() + await expect(report).toHaveCount(0) + await field.fill('2100-01') + await expect(field).toHaveAttribute('aria-invalid', 'true') + expect(requests).toHaveLength(0) + await field.fill('2026-07') + await expect(report).toContainText('2026-07 (UTC)') + await expect(field).toHaveAttribute('aria-invalid', 'false') + await field.focus() + await expect(field).toBeFocused() + await expect(page.getByRole('button', { name: 'Месяц', exact: true })).toHaveAttribute('aria-pressed', 'true') +})