diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 1d27ca49..b5c05e76 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -84,11 +84,11 @@ flowchart LR Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8](ux-ui-stage-8.md). -Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4М. +Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4Н. Ближайший оставшийся объём: -1. Проверка задержанного чтения внутри уже открытого SSE-потока. +1. Задержанное чтение открытого SSE проверено в блоке 8.4Н; продолжить со свежести данных ниже. 2. Свежесть данных трафика: ясное различие времени получения, истории и текущего live-измерения. 3. Сводная приёмка этапа 8 и оставшихся ограничений (включая выбор архивных серверов месячного отчёта), профильная регрессия и ReUI checklist. 4. После фиксации результата этапа 8 — этап 9. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 70dff456..45805ca5 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.4М; далее задержка внутри SSE, свежесть и приёмка | +| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.4Н; далее свежесть данных и приёмка | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.4Н — поздний фрагмент открытого SSE (2026-09-28) + +- Добавлен браузерный сценарий с управляемым ReadableStream: первый sample отображается, второй reader.read остаётся ожидающим, пользователь выбирает второй сервер. Проверены abort старой подписки, получение нового sample и завершение старого read после abort. Поздний фрагмент не заменяет данные второго сервера. +- Граница: fetch/ReadableStream подменены в браузере только для flow/live; поток намеренно игнорирует отмену, чтобы проверить защиту после await reader.read. Остальные HTTP-запросы используют прежние route-моки. Это не проверка реального сетевого транспорта или IPFIX/RouterOS. +- Все 20 flow-E2E прошли; ESLint теста, TypeScript (tsc --noEmit) и diff-check чистые. В первом прогоне исправлена ошибка тестового макета: Response должен создаваться до блокировки stream через getReader. Production-код не менялся, build не повторялся. +- Общий план синхронизирован. Следующий шаг: свежесть REST/SSE данных (разделять время получения и время измерения), затем сводная приёмка этапа 8. Этап остаётся открытым. На контрольной точке использовано 24% пятичасового и 4% недельного лимита; локальный коммит без push. + ## Блок 8.4М — разрывы основного графика (2026-09-24) - Существующий E2E пропусков дополнен проверкой SVG основного RX/TX: при [1, 2, null, 3, 4] каждая линия содержит два самостоятельных сегмента. Проверка прошла вместе со Sparkline; lint/diff-check чистые. Production-код не менялся. diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts index 289d1f2f..73340f2b 100644 --- a/tests/e2e/traffic-flow.spec.ts +++ b/tests/e2e/traffic-flow.spec.ts @@ -222,3 +222,66 @@ test('Flow: смена сервера отменяет старую SSE-подп await expect(page.getByText('SSE текущего сервера', { exact: true }).filter({ visible: true })).toBeVisible() await expect(page.getByLabel('Скорость потоков')).toContainText('7.00 Мбит/с') }) + +test('Flow: позднее чтение открытого SSE не заменяет данные нового сервера', async ({ page }) => { + await setup(page) + await page.getByRole('button', { name: '15м', exact: true }).click() + await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible() + // Deliberately let the mock stream deliver a chunk after abort. This exercises + // the post-read guard, independently of the browser transport's cancellation. + await page.evaluate(() => { + const originalFetch = window.fetch.bind(window) + const state = { pending: false, aborted: false, delivered: false, release: () => {} } + Object.assign(window, { delayedFlowStream: state }) + window.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), location.href) + if (url.pathname !== '/api/traffic/flow/live') return originalFetch(input, init) + const old = url.searchParams.get('serverId') === '1' + const sample = (late = false) => ({ + bpsNow: old ? (late ? 99000000 : 1000000) : 7000000, + bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, + topProto: 'TCP', topCategory: old ? (late ? 'Поздний фрагмент' : 'Открытый поток первого') : 'Открытый поток второго', + rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], + destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: true, + }) + const encode = (late = false) => new TextEncoder().encode('event: sample\ndata: ' + JSON.stringify(sample(late)) + '\n\n') + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encode()) + if (old) state.release = () => controller.enqueue(encode(true)) + }, + }) + const response = new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }) + if (old) { + init?.signal?.addEventListener('abort', () => { state.aborted = true }) + const reader = stream.getReader() + const read = reader.read.bind(reader) + let calls = 0 + reader.read = async () => { + const delayed = ++calls === 2 + if (delayed) state.pending = true + const result = await read() + if (delayed) { state.pending = false; state.delivered = true } + return result + } + stream.getReader = (() => reader) as typeof stream.getReader + } + return response + } + }) + await page.getByRole('button', { name: '5м', exact: true }).click() + await expect(page.getByText('Открытый поток первого', { exact: true }).filter({ visible: true })).toBeVisible() + const streamState = () => page.evaluate(() => { + const state = (window as unknown as { delayedFlowStream: { pending: boolean; aborted: boolean; delivered: boolean } }).delayedFlowStream + return { pending: state.pending, aborted: state.aborted, delivered: state.delivered } + }) + await expect.poll(streamState).toEqual({ pending: true, aborted: false, delivered: false }) + await page.getByRole('button').filter({ hasText: 'Экспортёр второй' }).click() + await expect(page.getByText('Открытый поток второго', { exact: true }).filter({ visible: true })).toBeVisible() + await expect.poll(streamState).toEqual({ pending: true, aborted: true, delivered: false }) + await page.evaluate(() => (window as unknown as { delayedFlowStream: { release: () => void } }).delayedFlowStream.release()) + await expect.poll(streamState).toEqual({ pending: false, aborted: true, delivered: true }) + await expect(page.getByText('Поздний фрагмент', { exact: true })).toHaveCount(0) + await expect(page.getByText('Открытый поток второго', { exact: true }).filter({ visible: true })).toBeVisible() + await expect(page.getByLabel('Скорость потоков')).toContainText('7.00 Мбит/с') +})