Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
94 lines
2.8 KiB
TypeScript
94 lines
2.8 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 (!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 (done) 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
|
|
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 }
|
|
}
|