diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 74f3bbd0..1ae64224 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -782,7 +782,7 @@ export default function TrafficPage() { const [search, setSearch] = useState("") const [trafficRetry, setTrafficRetry] = useState(0) const trafficKey = JSON.stringify([isLive, backendUrl, range, groupMode, trafficRetry]) - const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; error?: string } | null>(null) + const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; receivedAt?: number; error?: string } | null>(null) const trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready" const trafficLoading = trafficState?.key !== trafficKey const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined @@ -791,7 +791,7 @@ export default function TrafficPage() { const [selectedIface, setSelectedIface] = useState("__all__") const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true) const [detailRetry, setDetailRetry] = useState(0) - const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; error: string | null } | null>(null) + const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; receivedAt?: number; error: string | null } | null>(null) const [liveUsers, setLiveUsers] = useState([]) const [liveBoundIfaces, setLiveBoundIfaces] = useState([]) const [flowScope, setFlowScope] = useState("servers") @@ -819,7 +819,7 @@ export default function TrafficPage() { const detailKey = JSON.stringify([isLive, backendUrl, effectiveMode, selectedId, range, selectedIface, detailRetry]) const currentDetail = detailResult?.key === detailKey ? detailResult : null const detailBusy = isLive && effectiveMode === "servers" && Boolean(selectedId) && currentDetail === null - const { sample: liveSample, error: liveStreamError } = useTrafficLive({ + const { sample: liveSample, error: liveStreamError, receivedAt: liveReceivedAt } = useTrafficLive({ enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId), backendUrl, serverId: selectedId, @@ -860,7 +860,7 @@ export default function TrafficPage() { setLiveServers(mapped) setLiveUsers(mappedUsers) setLiveBoundIfaces(liveIfaces) - setTrafficState({ key, status: "ready" }) + setTrafficState({ key, status: "ready", receivedAt: Date.now() }) setSelectedId((prev) => { if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "") if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "") @@ -952,7 +952,7 @@ export default function TrafficPage() { apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`) .then((res) => { if (cancelled) return - setDetailResult({ key: detailKey, error: null, server: { + setDetailResult({ key: detailKey, receivedAt: Date.now(), error: null, server: { ...toLiveServer(res.server), boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId), } }) @@ -1183,6 +1183,7 @@ export default function TrafficPage() { items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems} />} + {isLive && effectiveMode !== "flows" && trafficReady && } {effectiveMode === "flows" ? (
@@ -1420,6 +1421,10 @@ export default function TrafficPage() { {detailBusy &&

Загрузка выбранного периода и интерфейса…

} {currentDetail?.error && Детали трафика недоступны{currentDetail.error}} } + {isLive && effectiveMode === "servers" && detailServer && <> + + + } {effectiveMode === "servers" && detailServer && ( Получено ({source}): - {" · время измерения не предоставлено"} + {serverAt && Number.isFinite(Date.parse(serverAt)) + ? <> · метка сервера: + : " · время измерения не предоставлено"}

) } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 3af06006..c747b309 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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О. В flow добавлены отметки получения API/SSE; свежесть остальных видов трафика ещё требует реализации. +Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4П. В flow и остальных видах трафика добавлены отметки получения API/SSE; для серверного SSE отдельно показана метка обработки ответа сервером. Далее — сводная приёмка этапа 8. Ближайший оставшийся объём: diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index ffcf0f34..11451680 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.4О; далее свежесть остальных видов трафика и приёмка | +| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.4П; далее сводная приёмка этапа 8 | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.4П — временные отметки остальных видов трафика (2026-09-28) + +- Общий REST-список серверов/клиентов/интерфейсов и история выбранного сервера показывают время успешного получения. При загрузке другого контекста или ошибке скрытые данные не оставляют прежнюю отметку. +- Серверный SSE хранит sample, receivedAt и ошибку вместе с ключом backend/сервера/интерфейса/режима. Удалён синхронный сброс через effect; hook проходит lint. Ошибка сохраняет время последнего валидного сообщения, смена интерфейса исключает чужое измерение. +- Поле at проверено в backend/src/services/traffic-rate.ts: parseMonitorTraffic создаёт его при обработке ответа. Поэтому подпись — «метка сервера», а не точное время измерения устройства. Время получения браузером отображается отдельно. Серверный контракт не изменён. +- Прошли 20 профильных E2E (детали, списки, SSE, мобильные клиенты/интерфейсы), затем 4 проверки SSE/flow после расширения сценария смены интерфейса. Финальная подпись проверена ещё 3 SSE-тестами и TypeScript. ESLint и diff-check чистые. Production build прошёл (34 страницы) до уточнения текста/имени prop; после уточнения выполнен tsc --noEmit --incremental false. Адаптивность 320/390 проверена тестами. +- Далее: сводная приёмка этапа 8 по журналу и stage-8: инвентаризация оставшихся проблем (неработающий экспорт, месячные ограничения, подписи текущих/исторических значений, молчащий SSE), затем согласованный набор регрессионных проверок. Таймаут SSE и достоверность времени устройства не реализованы этим блоком. Реальные RouterOS/IPFIX не проверены. На контрольной точке использовано 46% пятичасового и 7% недельного лимита. Локальный коммит без push. + ## Блок 8.4О — время получения flow-данных (2026-09-28) - Список потоков и выбранная аналитика/месячный отчёт показывают отдельное время успешного получения. Источник обозначен как API или live-поток. Подпись явно сообщает, что время измерения не предоставлено: локальные часы браузера не выдаются за метку сетевой телеметрии. diff --git a/hooks/use-traffic-live.ts b/hooks/use-traffic-live.ts index c1a03268..e6b938fa 100644 --- a/hooks/use-traffic-live.ts +++ b/hooks/use-traffic-live.ts @@ -24,20 +24,16 @@ export function useTrafficLive(opts: { backendUrl: string serverId: string iface: string -}): { sample: TrafficLiveSample | null; error: string | null } { - const [sample, setSample] = useState(null) - const [error, setError] = useState(null) +}): { sample: TrafficLiveSample | null; error: string | null; receivedAt: number | null } { + const key = JSON.stringify([opts.enabled, opts.backendUrl, opts.serverId, opts.iface]) + const [result, setResult] = useState<{ key: string; sample: TrafficLiveSample | null; receivedAt: number | null; error: string | null } | null>(null) useEffect(() => { - if (!opts.enabled || !opts.serverId) { - setSample(null) - setError(null) - return - } - + if (!opts.enabled || !opts.serverId) return const ac = new AbortController() - setSample(null) - setError(null) + const setError = (error: string) => { + if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, receivedAt: previous?.key === key ? previous.receivedAt : null, error })) + } const ifaceQ = opts.iface && opts.iface !== "__all__" ? `?iface=${encodeURIComponent(opts.iface)}` @@ -46,7 +42,6 @@ export function useTrafficLive(opts: { const url = resolveApiUrl(opts.backendUrl, path) let buf = "" - setError(null) void (async () => { try { @@ -80,8 +75,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-потока") } - setSample(parsed) - setError(null) + 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 } setError(parsed.error ?? "live error") @@ -95,7 +89,7 @@ export function useTrafficLive(opts: { })() return () => ac.abort() - }, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface]) + }, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface, key]) - return { sample, error } + return result?.key === key ? { sample: result.sample, error: result.error, receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null } } diff --git a/tests/e2e/traffic-details.spec.ts b/tests/e2e/traffic-details.spec.ts index ca5cf6a4..f4bde26d 100644 --- a/tests/e2e/traffic-details.spec.ts +++ b/tests/e2e/traffic-details.spec.ts @@ -35,6 +35,7 @@ test('Детали трафика: поздний период не заменя await started await expect(page.getByText('Загрузка выбранного периода и интерфейса…', { exact: true })).toBeVisible() await expect(page.getByLabel('Скорость выбранного сервера')).toHaveCount(0) + await expect(page.getByLabel('Получение истории сервера')).toHaveCount(0) await page.getByRole('button', { name: '15м', exact: true }).click() await expect(page.getByRole('heading', { name: 'Детали 15m __all__', exact: true })).toBeVisible() const late = page.waitForResponse(r => new URL(r.url()).pathname === '/api/traffic/servers/1' && r.url().includes('range=24h')) diff --git a/tests/e2e/traffic-live.spec.ts b/tests/e2e/traffic-live.spec.ts index ad9bc2a3..fce4b2fd 100644 --- a/tests/e2e/traffic-live.spec.ts +++ b/tests/e2e/traffic-live.spec.ts @@ -5,9 +5,10 @@ for (const variant of ['closed', 'invalid', 'http-error'] as const) { 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 } })) await page.route('**/api/**', route => { - const path = new URL(route.request().url()).pathname + const url = new URL(route.request().url()) + const path = url.pathname if (path.endsWith('/live')) { - if (variant === 'http-error') return route.fulfill({ status: 503, json: { error: 'Поток недоступен' } }) + if (variant === 'http-error' || url.searchParams.get('iface') === 'ether2') return route.fulfill({ status: 503, json: { error: 'Поток недоступен' } }) const sample = { rxMbps: 17, txMbps: 19, at: '2026-09-23T12:00:00Z' } const body = 'event: sample\ndata: ' + JSON.stringify(sample) + '\n\n' + (variant === 'invalid' ? 'event: sample\ndata: {"rxMbps":null,"txMbps":-1}\n\n' : '') return route.fulfill({ contentType: 'text/event-stream', body }) @@ -15,20 +16,37 @@ for (const variant of ['closed', 'invalid', 'http-error'] as const) { const json = path === '/api/auth/config' ? { required: false } : path === '/api/traffic/servers' ? { servers: [server] } : path === '/api/traffic/servers/1' ? { server } + : path === '/api/traffic/servers/1/interfaces' ? { interfaces: [{ name: 'ether2', running: true, disabled: false, rxNow: 0, txNow: 0 }] } : path.endsWith('/interfaces') || path === '/api/traffic/bound-interfaces' ? { interfaces: [] } : path === '/api/traffic/users' ? { users: [] } : path === '/api/servers' ? [] : {} return route.fulfill({ json }) }) + 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() 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) + await expect(page.getByLabel('Получение истории сервера').locator('time')).toHaveAttribute('datetime', received.toISOString()) + const measurement = page.getByLabel('Последнее измерение сервера') + if (variant === 'http-error') await expect(measurement).toHaveCount(0) if (variant !== 'http-error') { + await expect(measurement.locator('time').nth(0)).toHaveAttribute('datetime', received.toISOString()) + await expect(measurement).toContainText('метка сервера:') + await expect(measurement.locator('time').nth(1)).toHaveAttribute('datetime', '2026-09-23T12:00:00Z') + await page.clock.setFixedTime(new Date('2026-09-28T06:05:00Z')) + await expect(measurement.locator('time').nth(0)).toHaveAttribute('datetime', received.toISOString()) await expect(summary).toContainText('17.0') await expect(summary).toContainText('19.0') + if (variant === 'closed') { + await page.getByRole('button', { name: 'ether2', exact: true }).click() + await expect(summary.getByText('История · live недоступен', { exact: true }).filter({ visible: true })).toHaveCount(2) + await expect(measurement).toHaveCount(0) + await expect(summary).not.toContainText('17.0') + } } }) }