fix(ui): распознавать CRLF и комментарии в потоке SSE
This commit is contained in:
1 parent
f50a29f116
commit
ed491c313f
8 files changed
+38
-15
No files matched your search
+2
-2
@@ -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.
|
||||||
|
|
||||||
## Порядок продолжения и журналирования
|
## Порядок продолжения и журналирования
|
||||||
|
|||||||
@@ -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-признак отсутствия данных, не доказательство отказа устройства.
|
||||||
|
|||||||
@@ -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 прошли; далее одиночная точка графика и остальные ограничения, перечисленные выше.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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 Мбит/с')
|
||||||
|
|||||||
@@ -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!)
|
||||||
|
|||||||
Reference in new issue
Block a user