"use client" import { useEffect, useState } from "react" import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client" export interface TrafficLiveSample { rxMbps: number txMbps: number at: string timestampKind?: "backend-receipt" } function parseSseBlock(block: string): { event: string; data: string } { let event = "message" const dataLines: string[] = [] for (const line of block.split(/\r?\n/)) { if (line.startsWith("event:")) event = line.slice(6).trim() else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()) } return { event, data: dataLines.join("\n") } } export function useTrafficLive(opts: { enabled: boolean backendUrl: string serverId: string iface: string reloadKey?: number }): { sample: TrafficLiveSample | null; error: string | null; receivedAt: number | null } { const scope = JSON.stringify([opts.backendUrl, opts.serverId, opts.iface]) const key = JSON.stringify([opts.enabled, scope, opts.reloadKey ?? 0]) const [result, setResult] = useState<{ key: string; scope: string; sample: TrafficLiveSample | null; receivedAt: number | null; error: string | null } | null>(null) useEffect(() => { if (!opts.enabled || !opts.serverId) return const ac = new AbortController() let lastStreamError: string | null = null // A new connection owns the status; the same device's previous sample remains historical. // eslint-disable-next-line react-hooks/set-state-in-effect setResult(previous => ({ key, scope, sample: previous?.scope === scope ? previous.sample : null, receivedAt: previous?.scope === scope ? previous.receivedAt : null, error: "Ожидание нового live-измерения" })) let sampleTimer: ReturnType | undefined const stopTimer = () => clearTimeout(sampleTimer) const setError = (error: string) => { lastStreamError = error stopTimer() if (!ac.signal.aborted) setResult(previous => ({ key, scope, sample: previous?.scope === scope ? previous.sample : null, receivedAt: previous?.scope === scope ? previous.receivedAt : null, error })) } const ifaceQ = opts.iface && opts.iface !== "__all__" ? `?iface=${encodeURIComponent(opts.iface)}` : "" 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 = "" void (async () => { try { const res = await fetch(url, { headers: withAuthHeaders({ Accept: "text/event-stream" }), signal: ac.signal, credentials: "include", }) if (ac.signal.aborted) return if (!res.ok || !res.body) { const body = await res.json().catch(() => null) as { error?: unknown } | null setError(`${typeof body?.error === "string" ? body.error : "Live-поток недоступен"} (HTTP ${res.status})`) return } const reader = res.body.getReader() const decoder = new TextDecoder() while (!ac.signal.aborted) { const { done, value } = await reader.read() if (ac.signal.aborted) return if (done) { setError(lastStreamError ?? "Поток live завершён") break } buf += decoder.decode(value, { stream: true }) const parts = buf.split(/\r?\n\r?\n/) buf = parts.pop() ?? "" for (const raw of parts) { if (!raw.trim()) continue const ev = parseSseBlock(raw) if (ev.event === "sample" && ev.data) { const parsed = JSON.parse(ev.data) as TrafficLiveSample 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)) || parsed.timestampKind !== undefined && parsed.timestampKind !== "backend-receipt") { throw new Error("Некорректные данные live-потока") } awaitSample() lastStreamError = null setResult({ key, scope, sample: parsed, receivedAt: Date.now(), error: null }) } else if (ev.event === "error" && ev.data) { const parsed = JSON.parse(ev.data) as { error?: unknown } | null setError(typeof parsed?.error === "string" ? parsed.error : "Некорректный ответ об ошибке live-потока") } } } } catch (e) { if (ac.signal.aborted) return setError(e instanceof Error ? e.message : "live error") } finally { stopTimer() ac.abort() } })() return () => { stopTimer(); ac.abort() } }, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface, key, scope]) return opts.enabled && result?.scope === scope ? { sample: result.sample, error: result.key === key ? result.error : "Ожидание нового live-измерения", receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null } }