feat(traffic): показать аналитику IPFIX по серверам и интерфейсам
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 11s
Резолвить ifIndex в имена RouterOS, дать вкладке Потоки ту же оболочку сервер/клиент/iface, что у обычного трафика, и обновлять срезы live без перезагрузки. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import type { FlowAnalyticsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
|
||||
import { flowQuery } from "@/shared/api/traffic-flow"
|
||||
|
||||
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 useFlowLive(opts: {
|
||||
enabled: boolean
|
||||
backendUrl: string
|
||||
range: string
|
||||
serverId?: string
|
||||
userId?: string
|
||||
iface?: string
|
||||
}): { sample: FlowAnalyticsDto | null; error: string | null } {
|
||||
const [sample, setSample] = useState<FlowAnalyticsDto | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!opts.enabled) {
|
||||
setSample(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
const ac = new AbortController()
|
||||
setSample(null)
|
||||
setError(null)
|
||||
const path = `/api/traffic/flow/live${flowQuery({
|
||||
range: opts.range,
|
||||
serverId: opts.serverId,
|
||||
userId: opts.userId,
|
||||
iface: opts.iface,
|
||||
})}`
|
||||
const url = resolveApiUrl(opts.backendUrl, path)
|
||||
|
||||
let buf = ""
|
||||
|
||||
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) {
|
||||
setSample(JSON.parse(ev.data) as FlowAnalyticsDto)
|
||||
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.range, opts.serverId, opts.userId, opts.iface])
|
||||
|
||||
return { sample, error }
|
||||
}
|
||||
Reference in New Issue
Block a user