Files
MikrotikManager/hooks/use-traffic-live.ts
T

120 lines
5.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<typeof setTimeout> | 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 }
}