fix(ui): снимать статус live при отсутствии обновлений
This commit is contained in:
1 parent
5734340db2
commit
f50a29f116
8 files changed
+103
-5
No files matched your search
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, единичной точки графика и месячных ограничений; этап пока открыт.
|
||||
+13
-1
@@ -37,7 +37,10 @@ export function useFlowLive(opts: {
|
||||
useEffect(() => {
|
||||
if (!opts.enabled) return
|
||||
const ac = new AbortController()
|
||||
let sampleTimer: ReturnType<typeof setTimeout> | 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 }
|
||||
|
||||
@@ -31,7 +31,10 @@ export function useTrafficLive(opts: {
|
||||
useEffect(() => {
|
||||
if (!opts.enabled || !opts.serverId) return
|
||||
const ac = new AbortController()
|
||||
let sampleTimer: ReturnType<typeof setTimeout> | 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 }
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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())
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user