diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index dfa17225..cb78c132 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -88,7 +88,7 @@ flowchart LR Ближайший оставшийся объём: -1. Обработка молчащего открытого SSE: последнее значение/время, прекращение отметки live и возобновление после нового sample. +1. Молчащий SSE обработан в блоке 8.6 (15 секунд без валидного sample); продолжить аудит парсинга CRLF/heartbeat и ожидания первого сообщения. 2. Итоговая приёмка этапа 8: решение по архивным серверам/месяцам, проверка CRLF/heartbeat и единичной точки графика, 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 4bd4bf77..a9090704 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -24,13 +24,21 @@ | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | -| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.5; промежуточная приёмка пройдена, далее молчащий SSE | +| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.6; молчащий SSE обработан, далее оставшийся аудит | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.6 — молчащий открытый SSE (2026-09-28) + +- Оба live-hook запускают таймер ожидания валидного sample на 15 секунд. Основание: backend LIVE_TICK_MS равен 1500 мс для сервера и 2000 мс для flow, плюс время запроса. Порог допускает несколько пропущенных обновлений; это UI-признак отсутствия данных, не доказательство отказа устройства. +- По таймауту сохраняются последнее значение и время получения, снимается отметка live; новый валидный sample очищает ошибку и возобновляет ожидание. Heartbeat не обновляет свежесть. Таймер очищается при ошибке, завершении чтения и смене контекста. Подключение не разрывается только из-за таймаута, чтобы принять последующее сообщение. +- Прежние 24 SSE/flow-E2E прошли. Два новых сценария управляемого ReadableStream/часов проверили молчание, сохранение времени/значений, восстановление, смену периода flow и уход из серверного раздела. Первоначальные ожидания тестов уточнены (три live-отметки flow); оба новых сценария повторно прошли. ESLint, diff-check, production build/TypeScript прошли (34 страницы). +- Границы: тесты подменяют поток браузера, реальный транспорт/RouterOS не проверен. Нет автоматического переподключения закрытого SSE; отдельные проверки задержки до первого sample, heartbeat/CRLF и фонового throttling браузера остаются для аудита. +- Далее: оставшийся аудит парсинга SSE (CRLF/heartbeat), единичная точка основного графика, решение по месячным ограничениям и ReUI/accessibility checklist. Этап 8 ещё открыт. Использовано 84% пятичасового и 13% недельного лимита; новый блок в этом продолжении не начинаем. Локальный коммит без push. + ## Блок 8.5 — промежуточная приёмка и достоверность сводки (2026-09-28) - В flow-сводке устранена подмена выбранного среза общими метриками коллектора при недоступной аналитике. Удалён ошибочный пересчёт bytesPerMin в бит/с без деления на 60. Скорость/источники/протокол показывают «—» до подтверждения аналитики; доступное число экспортёров сохраняется. Ошибка и успешный повтор покрыты E2E. diff --git a/docs/ux-ui-stage-8.md b/docs/ux-ui-stage-8.md index 84150c4c..b7badecf 100644 --- a/docs/ux-ui-stage-8.md +++ b/docs/ux-ui-stage-8.md @@ -41,3 +41,6 @@ Этап 8 пока не закрыт. Следующий блок — обработка молчащего открытого SSE: после прекращения валидных sample нельзя бесконечно показывать live; сохранить последнее значение/время, обозначить отсутствие обновлений, проверить возобновление и смену контекста. Порог определить по фактической частоте backend и проверить управляемыми часами. После этого требуется итоговое решение по месячному отчёту: текущий список экспортёров охватывает только 24 часа, архивные серверы и прошлые месяцы недоступны. Экспорт трафика не реализован. Проверку CRLF/heartbeat и единичной точки основного графика включить в оставшийся аудит, затем закончить ReUI/accessibility checklist. Реальные RouterOS, JWT и IPFIX не проверены локальными HTTP/SSE-моками. + + +Обновление 8.6 (2026-09-28): молчащий SSE обработан в обоих hook — 15 секунд без валидного sample снимают live, сохраняя данные; следующий sample восстанавливает live. 24 прежних и 2 новых сценария прошли, как и lint/build. Следующий шаг — оставшийся аудит CRLF/heartbeat, первого sample, единичной точки графика и месячных ограничений; этап пока открыт. diff --git a/hooks/use-flow-live.ts b/hooks/use-flow-live.ts index 39fd700f..6e13c362 100644 --- a/hooks/use-flow-live.ts +++ b/hooks/use-flow-live.ts @@ -37,7 +37,10 @@ export function useFlowLive(opts: { useEffect(() => { if (!opts.enabled) return const ac = new AbortController() + let sampleTimer: ReturnType | undefined + const stopTimer = () => clearTimeout(sampleTimer) const setError = (error: string) => { + stopTimer() if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, receivedAt: previous?.key === key ? previous.receivedAt : null, error })) } const path = `/api/traffic/flow/live${flowQuery({ @@ -50,6 +53,12 @@ export function useFlowLive(opts: { excludeOverlay: opts.excludeOverlay, })}` const url = resolveApiUrl(opts.backendUrl, path) + // Backend ticks every 1.5–2s; tolerate several missed samples. Heartbeats do not reset this timer. + const awaitSample = () => { + stopTimer() + sampleTimer = setTimeout(() => setError("Нет обновлений live более 15 секунд"), 15_000) + } + awaitSample() let buf = "" @@ -82,6 +91,7 @@ export function useFlowLive(opts: { if (!isValidFlowLiveSample(parsed)) { throw new Error("Некорректные данные live-потока") } + awaitSample() setResult({ key, sample: parsed, receivedAt: Date.now(), error: parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null }) } else if (ev.event === "error" && ev.data) { const parsed: unknown = JSON.parse(ev.data) @@ -93,10 +103,12 @@ export function useFlowLive(opts: { if (ac.signal.aborted) return setError(e instanceof Error ? e.message : "Ошибка live-потока") ac.abort() + } finally { + stopTimer() } })() - return () => ac.abort() + return () => { stopTimer(); ac.abort() } }, [opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay, key]) return result?.key === key ? { sample: result.sample, error: result.error, receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null } diff --git a/hooks/use-traffic-live.ts b/hooks/use-traffic-live.ts index e6b938fa..3a8d3bcf 100644 --- a/hooks/use-traffic-live.ts +++ b/hooks/use-traffic-live.ts @@ -31,7 +31,10 @@ export function useTrafficLive(opts: { useEffect(() => { if (!opts.enabled || !opts.serverId) return const ac = new AbortController() + let sampleTimer: ReturnType | undefined + const stopTimer = () => clearTimeout(sampleTimer) const setError = (error: string) => { + stopTimer() if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, receivedAt: previous?.key === key ? previous.receivedAt : null, error })) } const ifaceQ = @@ -40,6 +43,12 @@ export function useTrafficLive(opts: { : "" const path = `/api/traffic/servers/${encodeURIComponent(opts.serverId)}/live${ifaceQ}` const url = resolveApiUrl(opts.backendUrl, path) + // Backend ticks every 1.5–2s; tolerate several missed samples. Heartbeats do not reset this timer. + const awaitSample = () => { + stopTimer() + sampleTimer = setTimeout(() => setError("Нет обновлений live более 15 секунд"), 15_000) + } + awaitSample() let buf = "" @@ -75,6 +84,7 @@ export function useTrafficLive(opts: { 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-потока") } + awaitSample() setResult({ key, sample: parsed, receivedAt: Date.now(), error: null }) } else if (ev.event === "error" && ev.data) { const parsed = JSON.parse(ev.data) as { error?: string } @@ -85,10 +95,12 @@ export function useTrafficLive(opts: { } catch (e) { if (ac.signal.aborted) return setError(e instanceof Error ? e.message : "live error") + } finally { + stopTimer() } })() - return () => ac.abort() + return () => { stopTimer(); ac.abort() } }, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface, key]) return result?.key === key ? { sample: result.sample, error: result.error, receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null } diff --git a/tests/e2e/live-stream-fixture.ts b/tests/e2e/live-stream-fixture.ts new file mode 100644 index 00000000..4132a77b --- /dev/null +++ b/tests/e2e/live-stream-fixture.ts @@ -0,0 +1,25 @@ +import type { Page } from '@playwright/test' + +// Keep a response open; the browser test controls sample delivery independently of HTTP routes. +export async function installOpenLiveStream(page: Page, path: string, sample: unknown) { + await page.clock.install() + await page.addInitScript(({ path, sample }) => { + const originalFetch = window.fetch.bind(window) + window.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), location.href) + 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 }) + send() + }, + }) + return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }) + } + }, { path, sample }) +} + +export async function resumeLiveStream(page: Page) { + await page.evaluate(() => (window as unknown as { sendLiveSample: () => void }).sendLiveSample()) +} diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts index c14def93..7e0df36a 100644 --- a/tests/e2e/traffic-flow.spec.ts +++ b/tests/e2e/traffic-flow.spec.ts @@ -1,3 +1,4 @@ +import { installOpenLiveStream, resumeLiveStream } from "./live-stream-fixture" import { test, expect, type Page } from '@playwright/test' async function setup(page: Page) { @@ -319,3 +320,24 @@ test('Flow: время получения принадлежит текущем await expect(page.getByRole('alert').filter({ hasText: 'Аналитика недоступна' })).toBeVisible() await expect(receipt).toHaveCount(0) }) + + +test('Flow: молчащий SSE сохраняет данные и восстанавливает live после sample', async ({ page }) => { + await installOpenLiveStream(page, '/api/traffic/flow/live', { bpsNow: 1000000, bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: true }) + await setup(page) + 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 expect(page.getByText('Нет обновлений live более 15 секунд', { exact: true })).toBeVisible() + await expect(page.getByText('live', { exact: true })).toHaveCount(0) + await expect(page.getByLabel('Скорость потоков')).toContainText('1.00 Мбит/с') + await expect(receipt).toHaveAttribute('datetime', stamp!) + await resumeLiveStream(page) + await expect(page.getByText('live', { exact: true }).filter({ visible: true })).toHaveCount(3) + await expect(page.getByText('Нет обновлений live более 15 секунд', { exact: true })).toHaveCount(0) + await page.getByRole('button', { name: '15м', exact: true }).click() + await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible() + await page.clock.fastForward(15_001) + await expect(page.getByText('Нет обновлений live более 15 секунд', { exact: true })).toHaveCount(0) +}) diff --git a/tests/e2e/traffic-live.spec.ts b/tests/e2e/traffic-live.spec.ts index fce4b2fd..2dfcf5e4 100644 --- a/tests/e2e/traffic-live.spec.ts +++ b/tests/e2e/traffic-live.spec.ts @@ -1,6 +1,7 @@ +import { installOpenLiveStream, resumeLiveStream } from "./live-stream-fixture" import { test, expect } from '@playwright/test' -for (const variant of ['closed', 'invalid', 'http-error'] as const) { +for (const variant of ['closed', 'invalid', 'http-error', 'silent'] 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 } })) @@ -22,11 +23,26 @@ for (const variant of ['closed', 'invalid', 'http-error'] as const) { : path === '/api/servers' ? [] : {} return route.fulfill({ json }) }) + if (variant === 'silent') await installOpenLiveStream(page, '/api/traffic/servers/1/live', { rxMbps: 17, txMbps: 19, at: '2026-09-23T12:00:00Z' }) const received = new Date('2026-09-28T06:00:00Z') await page.clock.setFixedTime(received) await page.goto('/traffic') const summary = page.getByLabel('Скорость выбранного сервера') await expect(summary).toBeVisible() + 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 expect(summary.getByText('live', { exact: true })).toHaveCount(0) + await expect(summary).toContainText('17.0') + await expect(page.getByLabel('Последнее измерение сервера').locator('time').first()).toHaveAttribute('datetime', stamp!) + await resumeLiveStream(page) + await expect(summary.getByText('live', { exact: true }).filter({ visible: true })).toHaveCount(2) + await page.getByRole('group', { name: 'Разделы трафика' }).getByRole('button', { name: 'Клиенты', exact: true }).click() + await page.clock.fastForward(15_001) + await expect(page.getByLabel('Последнее измерение сервера')).toHaveCount(0) + return + } 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)