feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,119 @@
|
||||
"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 }
|
||||
}
|
||||
Reference in new issue
Block a user