From ed491c313fba907e7362356326e09ad65994cdc8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 28 Sep 2026 11:38:11 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20CRLF=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B5=20SSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-plan.md | 4 ++-- docs/ux-ui-progress.md | 9 ++++++++- docs/ux-ui-stage-8.md | 2 ++ hooks/use-flow-live.ts | 6 +++--- hooks/use-traffic-live.ts | 6 +++--- tests/e2e/live-stream-fixture.ts | 14 ++++++++++++-- tests/e2e/traffic-flow.spec.ts | 6 ++++-- tests/e2e/traffic-live.spec.ts | 6 ++++-- 8 files changed, 38 insertions(+), 15 deletions(-) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index cb78c132..1857816d 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -88,8 +88,8 @@ flowchart LR Ближайший оставшийся объём: -1. Молчащий SSE обработан в блоке 8.6 (15 секунд без валидного sample); продолжить аудит парсинга CRLF/heartbeat и ожидания первого сообщения. -2. Итоговая приёмка этапа 8: решение по архивным серверам/месяцам, проверка CRLF/heartbeat и единичной точки графика, ReUI/accessibility checklist. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md). +1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Далее — одиночная точка основного графика и ожидание первого sample. +2. Итоговая приёмка этапа 8: решение по архивным серверам/месяцам, проверка единичной точки графика, 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 a9090704..4997a31a 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.6; молчащий SSE обработан, далее оставшийся аудит | +| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.7; CRLF/heartbeat проверены, далее оставшийся аудит | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.7 — CRLF и heartbeat SSE (2026-09-28) + +- В обоих hook разделители событий и строк принимают LF/CRLF, включая разделитель между двумя chunk. Комментарий перед event/data больше не отбрасывает весь блок: строки комментариев пропускаются парсером, sample сохраняется. +- Управляемый поток в двух сценариях отправляет CRLF-событие с начальным комментарием и разделённым delimiter. Heartbeat через 10 секунд не сбрасывает 15-секундный таймер валидного sample. Проверены сохранение данных, восстановление и уход из контекста. +- Все 26 flow/серверных SSE-E2E прошли; ESLint, diff-check и production build/TypeScript прошли (34 страницы). Это профильная проверка, не повтор полной регрессии этапа. Поддержка одиночного CR без LF не добавлена; реальный RouterOS/IPFIX не проверен. +- Следующий шаг: одиночная точка основного RX/TX-графика, затем ожидание первого sample, месячные ограничения и ReUI/accessibility checklist. Этап 8 остаётся открытым. Использовано 95% пятичасового и 15% недельного лимита; остаток сохранён для завершения и коммита. Локально, без push. + ## Блок 8.6 — молчащий открытый SSE (2026-09-28) - Оба live-hook запускают таймер ожидания валидного sample на 15 секунд. Основание: backend LIVE_TICK_MS равен 1500 мс для сервера и 2000 мс для flow, плюс время запроса. Порог допускает несколько пропущенных обновлений; это UI-признак отсутствия данных, не доказательство отказа устройства. diff --git a/docs/ux-ui-stage-8.md b/docs/ux-ui-stage-8.md index b7badecf..e2ac8a4c 100644 --- a/docs/ux-ui-stage-8.md +++ b/docs/ux-ui-stage-8.md @@ -44,3 +44,5 @@ Обновление 8.6 (2026-09-28): молчащий SSE обработан в обоих hook — 15 секунд без валидного sample снимают live, сохраняя данные; следующий sample восстанавливает live. 24 прежних и 2 новых сценария прошли, как и lint/build. Следующий шаг — оставшийся аудит CRLF/heartbeat, первого sample, единичной точки графика и месячных ограничений; этап пока открыт. + +Обновление 8.7 (2026-09-28): LF/CRLF и комментарий перед event поддержаны в обоих hook. Два потоковых сценария проверяют разделитель между chunk и heartbeat, не продлевающий свежесть. Все 26 профильных E2E прошли; далее одиночная точка графика и остальные ограничения, перечисленные выше. diff --git a/hooks/use-flow-live.ts b/hooks/use-flow-live.ts index 6e13c362..9471b11a 100644 --- a/hooks/use-flow-live.ts +++ b/hooks/use-flow-live.ts @@ -8,7 +8,7 @@ import { flowQuery } from "@/shared/api/traffic-flow" function parseSseBlock(block: string): { event: string; data: string } { let event = "message" const dataLines: string[] = [] - for (const line of block.split("\n")) { + for (const line of block.split(/\r?\n/)) { if (line.startsWith("event:")) event = line.slice(6).trim() else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()) } @@ -81,10 +81,10 @@ export function useFlowLive(opts: { if (ac.signal.aborted) return if (done) { setError("Поток live завершён"); break } buf += decoder.decode(value, { stream: true }) - const parts = buf.split("\n\n") + const parts = buf.split(/\r?\n\r?\n/) buf = parts.pop() ?? "" for (const raw of parts) { - if (!raw.trim() || raw.trim().startsWith(":")) continue + if (!raw.trim()) continue const ev = parseSseBlock(raw) if (ev.event === "sample" && ev.data) { const parsed = JSON.parse(ev.data) as unknown diff --git a/hooks/use-traffic-live.ts b/hooks/use-traffic-live.ts index 3a8d3bcf..57dd20e6 100644 --- a/hooks/use-traffic-live.ts +++ b/hooks/use-traffic-live.ts @@ -12,7 +12,7 @@ export interface TrafficLiveSample { function parseSseBlock(block: string): { event: string; data: string } { let event = "message" const dataLines: string[] = [] - for (const line of block.split("\n")) { + for (const line of block.split(/\r?\n/)) { if (line.startsWith("event:")) event = line.slice(6).trim() else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()) } @@ -74,10 +74,10 @@ export function useTrafficLive(opts: { break } buf += decoder.decode(value, { stream: true }) - const parts = buf.split("\n\n") + const parts = buf.split(/\r?\n\r?\n/) buf = parts.pop() ?? "" for (const raw of parts) { - if (!raw.trim() || raw.trim().startsWith(":")) continue + if (!raw.trim()) continue const ev = parseSseBlock(raw) if (ev.event === "sample" && ev.data) { const parsed = JSON.parse(ev.data) as TrafficLiveSample diff --git a/tests/e2e/live-stream-fixture.ts b/tests/e2e/live-stream-fixture.ts index 4132a77b..41eaad72 100644 --- a/tests/e2e/live-stream-fixture.ts +++ b/tests/e2e/live-stream-fixture.ts @@ -10,8 +10,14 @@ export async function installOpenLiveStream(page: Page, path: string, sample: un if (url.pathname !== path) return originalFetch(input, init) const stream = new ReadableStream({ start(controller) { - const send = () => controller.enqueue(new TextEncoder().encode('event: sample\ndata: ' + JSON.stringify(sample) + '\n\n')) - Object.assign(window, { sendLiveSample: send }) + const encode = (value: string) => new TextEncoder().encode(value) + const send = () => { + // A comment may precede an event; CRLF delimiters may cross chunks. + controller.enqueue(encode(': heartbeat\r\nevent: sample\r\ndata: ' + JSON.stringify(sample) + '\r\n\r')) + controller.enqueue(encode('\n')) + } + const heartbeat = () => controller.enqueue(encode(': heartbeat\r\n\r\n')) + Object.assign(window, { sendLiveSample: send, sendLiveHeartbeat: heartbeat }) send() }, }) @@ -23,3 +29,7 @@ export async function installOpenLiveStream(page: Page, path: string, sample: un export async function resumeLiveStream(page: Page) { await page.evaluate(() => (window as unknown as { sendLiveSample: () => void }).sendLiveSample()) } + +export async function sendLiveHeartbeat(page: Page) { + await page.evaluate(() => (window as unknown as { sendLiveHeartbeat: () => void }).sendLiveHeartbeat()) +} diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts index 7e0df36a..3f736af8 100644 --- a/tests/e2e/traffic-flow.spec.ts +++ b/tests/e2e/traffic-flow.spec.ts @@ -1,4 +1,4 @@ -import { installOpenLiveStream, resumeLiveStream } from "./live-stream-fixture" +import { installOpenLiveStream, resumeLiveStream, sendLiveHeartbeat } from "./live-stream-fixture" import { test, expect, type Page } from '@playwright/test' async function setup(page: Page) { @@ -328,7 +328,9 @@ test('Flow: молчащий SSE сохраняет данные и восста const receipt = page.getByLabel('Получение аналитики потоков').locator('time') await expect(page.getByText('live', { exact: true }).filter({ visible: true })).toHaveCount(3) const stamp = await receipt.getAttribute('datetime') - await page.clock.fastForward(15_001) + await page.clock.fastForward(10_000) + await sendLiveHeartbeat(page) + await page.clock.fastForward(5_001) await expect(page.getByText('Нет обновлений live более 15 секунд', { exact: true })).toBeVisible() await expect(page.getByText('live', { exact: true })).toHaveCount(0) await expect(page.getByLabel('Скорость потоков')).toContainText('1.00 Мбит/с') diff --git a/tests/e2e/traffic-live.spec.ts b/tests/e2e/traffic-live.spec.ts index 2dfcf5e4..740d382c 100644 --- a/tests/e2e/traffic-live.spec.ts +++ b/tests/e2e/traffic-live.spec.ts @@ -1,4 +1,4 @@ -import { installOpenLiveStream, resumeLiveStream } from "./live-stream-fixture" +import { installOpenLiveStream, resumeLiveStream, sendLiveHeartbeat } from "./live-stream-fixture" import { test, expect } from '@playwright/test' for (const variant of ['closed', 'invalid', 'http-error', 'silent'] as const) { @@ -32,7 +32,9 @@ for (const variant of ['closed', 'invalid', 'http-error', 'silent'] as const) { if (variant === 'silent') { await expect(summary.getByText('live', { exact: true }).filter({ visible: true })).toHaveCount(2) const stamp = await page.getByLabel('Последнее измерение сервера').locator('time').first().getAttribute('datetime') - await page.clock.fastForward(15_001) + await page.clock.fastForward(10_000) + await sendLiveHeartbeat(page) + await page.clock.fastForward(5_001) await expect(summary.getByText('live', { exact: true })).toHaveCount(0) await expect(summary).toContainText('17.0') await expect(page.getByLabel('Последнее измерение сервера').locator('time').first()).toHaveAttribute('datetime', stamp!)