102 lines
3.3 KiB
TypeScript
102 lines
3.3 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
|
|
|
|
export interface TrafficLiveSample {
|
|
rxMbps: number
|
|
txMbps: number
|
|
at: string
|
|
}
|
|
|
|
function parseSseBlock(block: string): { event: string; data: string } {
|
|
let event = "message"
|
|
const dataLines: string[] = []
|
|
for (const line of block.split("\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
|
|
}): { sample: TrafficLiveSample | null; error: string | null } {
|
|
const [sample, setSample] = useState<TrafficLiveSample | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!opts.enabled || !opts.serverId) {
|
|
setSample(null)
|
|
setError(null)
|
|
return
|
|
}
|
|
|
|
const ac = new AbortController()
|
|
setSample(null)
|
|
setError(null)
|
|
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)
|
|
|
|
let buf = ""
|
|
setError(null)
|
|
|
|
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) {
|
|
setError(`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("Поток live завершён")
|
|
break
|
|
}
|
|
buf += decoder.decode(value, { stream: true })
|
|
const parts = buf.split("\n\n")
|
|
buf = parts.pop() ?? ""
|
|
for (const raw of parts) {
|
|
if (!raw.trim() || raw.trim().startsWith(":")) 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))) {
|
|
throw new Error("Некорректные данные live-потока")
|
|
}
|
|
setSample(parsed)
|
|
setError(null)
|
|
} else if (ev.event === "error" && ev.data) {
|
|
const parsed = JSON.parse(ev.data) as { error?: string }
|
|
setError(parsed.error ?? "live error")
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (ac.signal.aborted) return
|
|
setError(e instanceof Error ? e.message : "live error")
|
|
}
|
|
})()
|
|
|
|
return () => ac.abort()
|
|
}, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface])
|
|
|
|
return { sample, error }
|
|
}
|