fix(ui): распознавать CRLF и комментарии в потоке SSE

This commit is contained in:
Denozordec committed 2026-09-28 11:38:11 +07:00
1 parent f50a29f116
commit ed491c313f
8 files changed
+38 -15

No files matched your search

+2 -2
View File
@@ -88,8 +88,8 @@ flowchart LR
Ближайший оставшийся объём: Ближайший оставшийся объём:
1. Молчащий SSE обработан в блоке 8.6 (15 секунд без валидного sample); продолжить аудит парсинга CRLF/heartbeat и ожидания первого сообщения. 1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Далее — одиночная точка основного графика и ожидание первого sample.
2. Итоговая приёмка этапа 8: решение по архивным серверам/месяцам, проверка CRLF/heartbeat и единичной точки графика, ReUI/accessibility checklist. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md). 2. Итоговая приёмка этапа 8: решение по архивным серверам/месяцам, проверка единичной точки графика, ReUI/accessibility checklist. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md).
3. После фиксации результата этапа 8 — этап 9. 3. После фиксации результата этапа 8 — этап 9.
## Порядок продолжения и журналирования ## Порядок продолжения и журналирования
+8 -1
View File
@@ -24,13 +24,20 @@
| 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.6; молчащий SSE обработан, далее оставшийся аудит | | 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.7; CRLF/heartbeat проверены, далее оставшийся аудит |
| 9 | VPN, Firewall и Filters | Не начат | | 9 | VPN, Firewall и Filters | Не начат |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 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) ## Блок 8.6 — молчащий открытый SSE (2026-09-28)
- Оба live-hook запускают таймер ожидания валидного sample на 15 секунд. Основание: backend LIVE_TICK_MS равен 1500 мс для сервера и 2000 мс для flow, плюс время запроса. Порог допускает несколько пропущенных обновлений; это UI-признак отсутствия данных, не доказательство отказа устройства. - Оба live-hook запускают таймер ожидания валидного sample на 15 секунд. Основание: backend LIVE_TICK_MS равен 1500 мс для сервера и 2000 мс для flow, плюс время запроса. Порог допускает несколько пропущенных обновлений; это UI-признак отсутствия данных, не доказательство отказа устройства.
+2
View File
@@ -44,3 +44,5 @@
Обновление 8.6 (2026-09-28): молчащий SSE обработан в обоих hook — 15 секунд без валидного sample снимают live, сохраняя данные; следующий sample восстанавливает live. 24 прежних и 2 новых сценария прошли, как и lint/build. Следующий шаг — оставшийся аудит CRLF/heartbeat, первого sample, единичной точки графика и месячных ограничений; этап пока открыт. Обновление 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 прошли; далее одиночная точка графика и остальные ограничения, перечисленные выше.
+3 -3
View File
@@ -8,7 +8,7 @@ import { flowQuery } from "@/shared/api/traffic-flow"
function parseSseBlock(block: string): { event: string; data: string } { function parseSseBlock(block: string): { event: string; data: string } {
let event = "message" let event = "message"
const dataLines: string[] = [] 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() if (line.startsWith("event:")) event = line.slice(6).trim()
else if (line.startsWith("data:")) dataLines.push(line.slice(5).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 (ac.signal.aborted) return
if (done) { setError("Поток live завершён"); break } if (done) { setError("Поток live завершён"); break }
buf += decoder.decode(value, { stream: true }) buf += decoder.decode(value, { stream: true })
const parts = buf.split("\n\n") const parts = buf.split(/\r?\n\r?\n/)
buf = parts.pop() ?? "" buf = parts.pop() ?? ""
for (const raw of parts) { for (const raw of parts) {
if (!raw.trim() || raw.trim().startsWith(":")) continue if (!raw.trim()) continue
const ev = parseSseBlock(raw) const ev = parseSseBlock(raw)
if (ev.event === "sample" && ev.data) { if (ev.event === "sample" && ev.data) {
const parsed = JSON.parse(ev.data) as unknown const parsed = JSON.parse(ev.data) as unknown
+3 -3
View File
@@ -12,7 +12,7 @@ export interface TrafficLiveSample {
function parseSseBlock(block: string): { event: string; data: string } { function parseSseBlock(block: string): { event: string; data: string } {
let event = "message" let event = "message"
const dataLines: string[] = [] 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() if (line.startsWith("event:")) event = line.slice(6).trim()
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()) else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim())
} }
@@ -74,10 +74,10 @@ export function useTrafficLive(opts: {
break break
} }
buf += decoder.decode(value, { stream: true }) buf += decoder.decode(value, { stream: true })
const parts = buf.split("\n\n") const parts = buf.split(/\r?\n\r?\n/)
buf = parts.pop() ?? "" buf = parts.pop() ?? ""
for (const raw of parts) { for (const raw of parts) {
if (!raw.trim() || raw.trim().startsWith(":")) continue if (!raw.trim()) continue
const ev = parseSseBlock(raw) const ev = parseSseBlock(raw)
if (ev.event === "sample" && ev.data) { if (ev.event === "sample" && ev.data) {
const parsed = JSON.parse(ev.data) as TrafficLiveSample const parsed = JSON.parse(ev.data) as TrafficLiveSample
+12 -2
View File
@@ -10,8 +10,14 @@ export async function installOpenLiveStream(page: Page, path: string, sample: un
if (url.pathname !== path) return originalFetch(input, init) if (url.pathname !== path) return originalFetch(input, init)
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
start(controller) { start(controller) {
const send = () => controller.enqueue(new TextEncoder().encode('event: sample\ndata: ' + JSON.stringify(sample) + '\n\n')) const encode = (value: string) => new TextEncoder().encode(value)
Object.assign(window, { sendLiveSample: send }) 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() send()
}, },
}) })
@@ -23,3 +29,7 @@ export async function installOpenLiveStream(page: Page, path: string, sample: un
export async function resumeLiveStream(page: Page) { export async function resumeLiveStream(page: Page) {
await page.evaluate(() => (window as unknown as { sendLiveSample: () => void }).sendLiveSample()) 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())
}
+4 -2
View File
@@ -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' import { test, expect, type Page } from '@playwright/test'
async function setup(page: Page) { async function setup(page: Page) {
@@ -328,7 +328,9 @@ test('Flow: молчащий SSE сохраняет данные и восста
const receipt = page.getByLabel('Получение аналитики потоков').locator('time') const receipt = page.getByLabel('Получение аналитики потоков').locator('time')
await expect(page.getByText('live', { exact: true }).filter({ visible: true })).toHaveCount(3) await expect(page.getByText('live', { exact: true }).filter({ visible: true })).toHaveCount(3)
const stamp = await receipt.getAttribute('datetime') 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 более 15 секунд', { exact: true })).toBeVisible()
await expect(page.getByText('live', { exact: true })).toHaveCount(0) await expect(page.getByText('live', { exact: true })).toHaveCount(0)
await expect(page.getByLabel('Скорость потоков')).toContainText('1.00 Мбит/с') await expect(page.getByLabel('Скорость потоков')).toContainText('1.00 Мбит/с')
+4 -2
View File
@@ -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' import { test, expect } from '@playwright/test'
for (const variant of ['closed', 'invalid', 'http-error', 'silent'] as const) { 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') { if (variant === 'silent') {
await expect(summary.getByText('live', { exact: true }).filter({ visible: true })).toHaveCount(2) await expect(summary.getByText('live', { exact: true }).filter({ visible: true })).toHaveCount(2)
const stamp = await page.getByLabel('Последнее измерение сервера').locator('time').first().getAttribute('datetime') 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.getByText('live', { exact: true })).toHaveCount(0)
await expect(summary).toContainText('17.0') await expect(summary).toContainText('17.0')
await expect(page.getByLabel('Последнее измерение сервера').locator('time').first()).toHaveAttribute('datetime', stamp!) await expect(page.getByLabel('Последнее измерение сервера').locator('time').first()).toHaveAttribute('datetime', stamp!)