From 9eb6a3fad18a23b91c9a6f2ae9642a47d6b5cb8f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 24 Sep 2026 01:13:11 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D1=83=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0=20liv?= =?UTF-8?q?e-=D1=82=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/traffic/page.tsx | 4 +++- docs/ux-ui-progress.md | 7 +++++++ hooks/use-traffic-live.ts | 10 +++++++++- tests/e2e/traffic-live.spec.ts | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/traffic-live.spec.ts diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 03127a0c..7a58736d 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -1464,7 +1464,9 @@ export default function TrafficPage() { setRange={setRange} liveRx={liveSample?.rxMbps} liveTx={liveSample?.txMbps} - liveHint={liveSample ? "live" : (liveStreamError ? "история" : undefined)} + liveHint={liveStreamError + ? (liveSample ? "Последнее live-измерение · поток недоступен" : "История · live недоступен") + : liveSample ? "live" : isLive ? "История · ожидание live" : undefined} /> )} {effectiveMode === "users" && selUser && } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index da6e1203..b132d5bd 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -29,6 +29,13 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.3А — достоверность live-подписей трафика (2026-09-24) + +- useTrafficLive сообщает завершение SSE, проверяет отмену после fetch/read и не применяет некорректные sample (нечисловые/отрицательные RX/TX или неверное время). Последний корректный sample сохраняется при сбое, но подписан «Последнее live-измерение · поток недоступен»; история при отсутствии sample помечена отдельно, включая ожидание live. +- 3 E2E прошли: штатное завершение потока, повреждённое событие после корректного sample, HTTP 503. Неправильное событие не заменяет последнее корректное измерение. Lint теста чистый; сравнение с HEAD подтвердило прежние замечания traffic (3 ошибки/1 предупреждение), hook (1 ошибка). Production build с TypeScript прошёл, 34 страницы. +- Коммит отложился до продолжения после сброса квоты: в предыдущем окне она достигла 100%, сборка уже была запущена и завершилась успешно. На продолжении доступно 99% пятичасового окна. Локальный коммит без push. +- Далее 8.3Б: гонки чтения истории/деталей при смене сервера, интерфейса и периода; API-ошибка не должна оставлять данные другого контекста. Автоматическое переподключение SSE, парсер CRLF, useFlowLive и реальный IPFIX остаются непроверенными. Этап 8 целиком не завершён. + ## Блок 8.2Б — URL-контекст статистики (2026-09-23) - Проверяются дубли/пустые значения, dim/tab, view, planes, измерения pivot, ID сервера, ASN и длина кода страны. Неверный срез блокирует запрос, а не удаляется из API-query с расширением отчёта. Совпадающие dim/tab поддержаны; смена dim удаляет устаревший tab. Сброс удаляет параметры контекста, сохраняя from/to, чужие ключи и hash. diff --git a/hooks/use-traffic-live.ts b/hooks/use-traffic-live.ts index 0ce1f2fb..c1a03268 100644 --- a/hooks/use-traffic-live.ts +++ b/hooks/use-traffic-live.ts @@ -55,6 +55,7 @@ export function useTrafficLive(opts: { signal: ac.signal, credentials: "include", }) + if (ac.signal.aborted) return if (!res.ok || !res.body) { setError(`live HTTP ${res.status}`) return @@ -63,7 +64,11 @@ export function useTrafficLive(opts: { const decoder = new TextDecoder() while (!ac.signal.aborted) { const { done, value } = await reader.read() - if (done) break + if (ac.signal.aborted) return + if (done) { + setError("Поток live завершён") + break + } buf += decoder.decode(value, { stream: true }) const parts = buf.split("\n\n") buf = parts.pop() ?? "" @@ -72,6 +77,9 @@ export function useTrafficLive(opts: { const ev = parseSseBlock(raw) if (ev.event === "sample" && ev.data) { const parsed = JSON.parse(ev.data) as TrafficLiveSample + if (!parsed || !Number.isFinite(parsed.rxMbps) || !Number.isFinite(parsed.txMbps) || parsed.rxMbps < 0 || parsed.txMbps < 0 || typeof parsed.at !== "string" || !Number.isFinite(Date.parse(parsed.at))) { + throw new Error("Некорректные данные live-потока") + } setSample(parsed) setError(null) } else if (ev.event === "error" && ev.data) { diff --git a/tests/e2e/traffic-live.spec.ts b/tests/e2e/traffic-live.spec.ts new file mode 100644 index 00000000..ad9bc2a3 --- /dev/null +++ b/tests/e2e/traffic-live.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test' + +for (const variant of ['closed', 'invalid', 'http-error'] as const) { + test(`Трафик: SSE ${variant} не выдаёт старое измерение за live`, async ({ page }) => { + const server = { id: '1', name: 'Тестовый сервер', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 5, txPeak: 6, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] } + await page.route('**/health', route => route.fulfill({ json: { ok: true } })) + await page.route('**/api/**', route => { + const path = new URL(route.request().url()).pathname + if (path.endsWith('/live')) { + if (variant === 'http-error') return route.fulfill({ status: 503, json: { error: 'Поток недоступен' } }) + const sample = { rxMbps: 17, txMbps: 19, at: '2026-09-23T12:00:00Z' } + const body = 'event: sample\ndata: ' + JSON.stringify(sample) + '\n\n' + (variant === 'invalid' ? 'event: sample\ndata: {"rxMbps":null,"txMbps":-1}\n\n' : '') + return route.fulfill({ contentType: 'text/event-stream', body }) + } + const json = path === '/api/auth/config' ? { required: false } + : path === '/api/traffic/servers' ? { servers: [server] } + : path === '/api/traffic/servers/1' ? { server } + : path.endsWith('/interfaces') || path === '/api/traffic/bound-interfaces' ? { interfaces: [] } + : path === '/api/traffic/users' ? { users: [] } + : path === '/api/servers' ? [] : {} + return route.fulfill({ json }) + }) + await page.goto('/traffic') + const summary = page.getByLabel('Скорость выбранного сервера') + await expect(summary).toBeVisible() + const hint = variant === 'http-error' ? 'История · live недоступен' : 'Последнее live-измерение · поток недоступен' + await expect(summary.getByText(hint, { exact: true }).filter({ visible: true })).toHaveCount(2) + await expect(summary.getByText('live', { exact: true })).toHaveCount(0) + if (variant !== 'http-error') { + await expect(summary).toContainText('17.0') + await expect(summary).toContainText('19.0') + } + }) +}