fix(ui): показывать время получения аналитики потоков
This commit is contained in:
1 parent
7318c5286b
commit
65241c94e9
6 files changed
+70
-12
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useMemo, useEffect, type ReactNode } from "react"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { TrafficDataTime } from "@/components/traffic/traffic-data-time"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
@@ -796,7 +797,7 @@ export default function TrafficPage() {
|
||||
const [flowScope, setFlowScope] = useState<FlowScope>("servers")
|
||||
const [flowRetry, setFlowRetry] = useState(0)
|
||||
const flowKey = JSON.stringify([isLive, groupMode, backendUrl, range, flowScope, flowRetry])
|
||||
const [flowResult, setFlowResult] = useState<{ key: string; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null)
|
||||
const [flowResult, setFlowResult] = useState<{ key: string; receivedAt?: number; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null)
|
||||
const currentFlows = flowResult?.key === flowKey ? flowResult : null
|
||||
const liveBusy = isLive && groupMode === "flows" && !currentFlows
|
||||
const liveError = currentFlows?.error
|
||||
@@ -804,7 +805,7 @@ export default function TrafficPage() {
|
||||
const flowExporters = useMemo(() => currentFlows?.exporters ?? [], [currentFlows])
|
||||
const flowClients = useMemo(() => currentFlows?.clients ?? [], [currentFlows])
|
||||
const [analyticsRetry, setAnalyticsRetry] = useState(0)
|
||||
const [analyticsResult, setAnalyticsResult] = useState<{ key: string; data?: FlowAnalyticsDto; monthly?: FlowMonthlyDto; error?: string } | null>(null)
|
||||
const [analyticsResult, setAnalyticsResult] = useState<{ key: string; receivedAt?: number; data?: FlowAnalyticsDto; monthly?: FlowMonthlyDto; error?: string } | null>(null)
|
||||
const flowIfaceOwner = JSON.stringify([isLive, backendUrl, groupMode, flowScope, selectedId])
|
||||
const [flowIfaceSelection, setFlowIfaceSelection] = useState<{ owner: string; iface: string } | null>(null)
|
||||
const flowIface = flowIfaceSelection?.owner === flowIfaceOwner ? flowIfaceSelection.iface : "__all__"
|
||||
@@ -825,7 +826,7 @@ export default function TrafficPage() {
|
||||
iface: selectedIface,
|
||||
})
|
||||
const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) && range === "5m"
|
||||
const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({
|
||||
const { sample: flowLiveSample, error: flowLiveError, receivedAt: flowLiveReceivedAt } = useFlowLive({
|
||||
enabled: flowLiveEnabled,
|
||||
backendUrl,
|
||||
range,
|
||||
@@ -882,7 +883,7 @@ export default function TrafficPage() {
|
||||
getFlowClients(backendUrl, range === "30d" ? "24h" : range),
|
||||
]).then(([stats, exporters, clients]) => {
|
||||
if (cancelled) return
|
||||
setFlowResult({ key: flowKey, stats, exporters: exporters.exporters, clients: clients.clients })
|
||||
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
|
||||
setSelectedId(prev => {
|
||||
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
||||
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
|
||||
@@ -907,7 +908,7 @@ export default function TrafficPage() {
|
||||
iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay,
|
||||
}).then(data => ({ data }))
|
||||
void request.then(result => {
|
||||
if (!cancelled) setAnalyticsResult({ key: analyticsKey, ...result })
|
||||
if (!cancelled) setAnalyticsResult({ key: analyticsKey, receivedAt: Date.now(), ...result })
|
||||
}).catch(e => {
|
||||
if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" })
|
||||
})
|
||||
@@ -1193,6 +1194,7 @@ export default function TrafficPage() {
|
||||
Подключить JH
|
||||
</Button>
|
||||
</div>
|
||||
{isLive && <TrafficDataTime label="Получение списка потоков" receivedAt={currentFlows?.receivedAt} source="API" />}
|
||||
{liveBusy && <p role="status">Загрузка потоков…</p>}
|
||||
{liveError && <Alert variant="destructive" role="alert"><AlertTitle>Потоки недоступны</AlertTitle><AlertDescription>{liveError}<Button size="sm" variant="outline" onClick={() => setFlowRetry(value => value + 1)}>Повторить загрузку потоков</Button></AlertDescription></Alert>}
|
||||
{flowError && !liveError && (
|
||||
@@ -1277,6 +1279,7 @@ export default function TrafficPage() {
|
||||
<FramePanel className="flex-1 min-w-0 p-3 sm:p-5">
|
||||
{analyticsLoading && <p role="status">Загрузка аналитики…</p>}
|
||||
{currentAnalytics?.error && <Alert variant="destructive" role="alert"><AlertTitle>Аналитика недоступна</AlertTitle><AlertDescription>{currentAnalytics.error}<Button size="sm" variant="outline" onClick={() => setAnalyticsRetry(value => value + 1)}>Повторить аналитику</Button></AlertDescription></Alert>}
|
||||
{isLive && (displayedFlow || currentAnalytics?.monthly) && <TrafficDataTime label="Получение аналитики потоков" receivedAt={flowLiveSample ? flowLiveReceivedAt : currentAnalytics?.receivedAt} source={flowLiveSample ? "live-поток" : "API"} />}
|
||||
<FlowAnalyticsDetail
|
||||
card={selFlowCard}
|
||||
analytics={displayedFlow}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Receipt time is not the timestamp of a network measurement. */
|
||||
export function TrafficDataTime({ label, receivedAt, source }: {
|
||||
label: string
|
||||
receivedAt?: number | null
|
||||
source: "API" | "live-поток"
|
||||
}) {
|
||||
if (receivedAt == null || !Number.isFinite(receivedAt)) return null
|
||||
const date = new Date(receivedAt)
|
||||
return (
|
||||
<p aria-label={label} className="text-xs text-muted-foreground mb-3 break-words">
|
||||
Получено ({source}): <time dateTime={date.toISOString()}>{date.toLocaleString("ru-RU")}</time>
|
||||
{" · время измерения не предоставлено"}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -84,7 +84,7 @@ flowchart LR
|
||||
|
||||
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8](ux-ui-stage-8.md).
|
||||
|
||||
Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4Н.
|
||||
Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4О. В flow добавлены отметки получения API/SSE; свежесть остальных видов трафика ещё требует реализации.
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
|
||||
@@ -24,13 +24,20 @@
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.4Н; далее свежесть данных и приёмка |
|
||||
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.4О; далее свежесть остальных видов трафика и приёмка |
|
||||
| 9 | VPN, Firewall и Filters | Не начат |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 8.4О — время получения flow-данных (2026-09-28)
|
||||
|
||||
- Список потоков и выбранная аналитика/месячный отчёт показывают отдельное время успешного получения. Источник обозначен как API или live-поток. Подпись явно сообщает, что время измерения не предоставлено: локальные часы браузера не выдаются за метку сетевой телеметрии.
|
||||
- useFlowLive сохраняет receivedAt вместе с валидным sample и ключом среза. Ошибка/закрытие потока сохраняют эту отметку; невалидный sample не обновляет её. При смене среза, загрузке или ошибке без отображаемых данных прежняя отметка скрыта. Mock-режим не получает фиктивную метку API.
|
||||
- 21 flow-E2E прошёл, включая управляемое время, задержку нового среза и ошибку после успешного sample. После уточнения скрытия отметок при ошибке списка повторно прошли 2 профильных E2E. ESLint, итоговый TypeScript и diff-check чистые. Production build прошёл (34 страницы); финальное условие скрытия дополнительно проверено TypeScript/E2E. Мобильный экран 390px осмотрен, проверки 320/390/1440 прошли.
|
||||
- Следующий шаг: время получения REST-списков серверов/клиентов/интерфейсов и деталей сервера; отдельно использовать at серверного SSE как время измерения. Затем сводная приёмка этапа 8. Таймаут молчащего SSE этим блоком не реализован; наличие метки получения не доказывает свежесть измерения. Реальный IPFIX/RouterOS не проверен. Использовано 34% пятичасового и 5% недельного лимита на контрольной точке. Локальный коммит без push.
|
||||
|
||||
## Блок 8.4Н — поздний фрагмент открытого SSE (2026-09-28)
|
||||
|
||||
- Добавлен браузерный сценарий с управляемым ReadableStream: первый sample отображается, второй reader.read остаётся ожидающим, пользователь выбирает второй сервер. Проверены abort старой подписки, получение нового sample и завершение старого read после abort. Поздний фрагмент не заменяет данные второго сервера.
|
||||
|
||||
@@ -30,15 +30,15 @@ export function useFlowLive(opts: {
|
||||
dedup?: boolean
|
||||
excludeMesh?: boolean
|
||||
excludeOverlay?: boolean
|
||||
}): { sample: FlowAnalyticsDto | null; error: string | null } {
|
||||
}): { sample: FlowAnalyticsDto | null; error: string | null; receivedAt: number | null } {
|
||||
const key = JSON.stringify([opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay])
|
||||
const [result, setResult] = useState<{ key: string; sample: FlowAnalyticsDto | null; error: string | null } | null>(null)
|
||||
const [result, setResult] = useState<{ key: string; sample: FlowAnalyticsDto | null; error: string | null; receivedAt: number | null } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!opts.enabled) return
|
||||
const ac = new AbortController()
|
||||
const setError = (error: string) => {
|
||||
if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, error }))
|
||||
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({
|
||||
range: opts.range,
|
||||
@@ -82,7 +82,7 @@ export function useFlowLive(opts: {
|
||||
if (!isValidFlowLiveSample(parsed)) {
|
||||
throw new Error("Некорректные данные live-потока")
|
||||
}
|
||||
setResult({ key, sample: parsed, error: parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null })
|
||||
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)
|
||||
throw new Error(parsed && typeof parsed === "object" && "error" in parsed && typeof parsed.error === "string" ? parsed.error : "Ошибка live-потока")
|
||||
@@ -99,5 +99,5 @@ export function useFlowLive(opts: {
|
||||
return () => 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 } : { sample: null, error: null }
|
||||
return result?.key === key ? { sample: result.sample, error: result.error, receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null }
|
||||
}
|
||||
@@ -64,6 +64,8 @@ test('Flow: ошибка списка не отображается как от
|
||||
await page.getByRole('button', { name: 'Обновить', exact: true }).click()
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Потоки недоступны' })).toContainText('Нет доступа')
|
||||
await expect(page.getByLabel('Сводка трафика')).toHaveCount(0)
|
||||
await expect(page.getByLabel('Получение аналитики потоков')).toHaveCount(0)
|
||||
await expect(page.getByLabel('Получение списка потоков')).toHaveCount(0)
|
||||
await expect(page.getByText('Нет экспортёров IPFIX.', { exact: false })).toHaveCount(0)
|
||||
control.failList = false
|
||||
await page.getByRole('button', { name: 'Повторить загрузку потоков', exact: true }).click()
|
||||
@@ -285,3 +287,34 @@ test('Flow: позднее чтение открытого SSE не заменя
|
||||
await expect(page.getByText('Открытый поток второго', { exact: true }).filter({ visible: true })).toBeVisible()
|
||||
await expect(page.getByLabel('Скорость потоков')).toContainText('7.00 Мбит/с')
|
||||
})
|
||||
|
||||
test('Flow: время получения принадлежит текущему срезу и сохраняется после ошибки SSE', async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
const receipt = page.getByLabel('Получение аналитики потоков')
|
||||
const first = new Date('2026-09-28T06:00:00Z')
|
||||
await page.clock.setFixedTime(first)
|
||||
await page.getByRole('button', { name: '15м', exact: true }).click()
|
||||
await expect(receipt.locator('time')).toHaveAttribute('datetime', first.toISOString())
|
||||
await expect(receipt).toContainText('Получено (API)')
|
||||
await expect(receipt).toContainText('время измерения не предоставлено')
|
||||
control.hold = '24h'
|
||||
const started = page.waitForRequest(r => r.url().includes('/flow/analytics?') && r.url().includes('range=24h'))
|
||||
await page.getByRole('button', { name: '24ч', exact: true }).click()
|
||||
await started
|
||||
await expect(receipt).toHaveCount(0)
|
||||
const second = new Date('2026-09-28T06:05:00Z')
|
||||
await page.clock.setFixedTime(second)
|
||||
control.release()
|
||||
await expect(receipt.locator('time')).toHaveAttribute('datetime', second.toISOString())
|
||||
control.invalidSample = null
|
||||
await page.getByRole('button', { name: '5м', exact: true }).click()
|
||||
await expect(page.getByText('Некорректные данные live-потока', { exact: true })).toBeVisible()
|
||||
await expect(receipt).toContainText('Получено (live-поток)')
|
||||
await expect(receipt.locator('time')).toHaveAttribute('datetime', second.toISOString())
|
||||
await page.clock.setFixedTime(new Date('2026-09-28T06:10:00Z'))
|
||||
await expect(receipt.locator('time')).toHaveAttribute('datetime', second.toISOString())
|
||||
control.fail = true
|
||||
await page.getByRole('button', { name: '15м', exact: true }).click()
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Аналитика недоступна' })).toBeVisible()
|
||||
await expect(receipt).toHaveCount(0)
|
||||
})
|
||||
Reference in new issue
Block a user