fix(ui): привязать flow-аналитику к контексту запроса

This commit is contained in:
Denozordec committed 2026-09-24 01:31:43 +07:00
1 parent 94dd453b33
commit 24313340d5
5 files changed
+163 -82

No files matched your search

+63 -66
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useMemo, useEffect, useCallback, type ReactNode } from "react"
import { useState, useMemo, useEffect, type ReactNode } from "react"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { PageHeader } from "@/components/page-header"
import { Frame, FramePanel } from "@/components/reui/frame"
@@ -840,8 +840,6 @@ export default function TrafficPage() {
const trafficLoading = trafficState?.key !== trafficKey
const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
const [liveBusy, setLiveBusy] = useState(false)
const [liveError, setLiveError] = useState<string | null>(null)
const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([])
const [selectedIface, setSelectedIface] = useState("__all__")
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
@@ -849,11 +847,18 @@ export default function TrafficPage() {
const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; error: string | null } | null>(null)
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
const [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
const [flowScope, setFlowScope] = useState<FlowScope>("servers")
const [flowExporters, setFlowExporters] = useState<FlowEntityCard[]>([])
const [flowClients, setFlowClients] = useState<FlowEntityCard[]>([])
const [flowAnalytics, setFlowAnalytics] = useState<FlowAnalyticsDto | null>(null)
const [flowRetry, setFlowRetry] = useState(0)
const flowKey = JSON.stringify([isLive, groupMode, backendUrl, range, flowScope, flowRetry])
const [flowResult, setFlowResult] = useState<{ key: string; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null)
const currentFlows = flowResult?.key === flowKey ? flowResult : null
const liveBusy = isLive && groupMode === "flows" && !currentFlows
const liveError = currentFlows?.error
const flowStats = currentFlows?.stats ?? null
const flowExporters = useMemo(() => currentFlows?.exporters ?? [], [currentFlows])
const flowClients = useMemo(() => currentFlows?.clients ?? [], [currentFlows])
const [analyticsRetry, setAnalyticsRetry] = useState(0)
const [analyticsResult, setAnalyticsResult] = useState<{ key: string; data?: FlowAnalyticsDto; error?: string } | null>(null)
const [flowIface, setFlowIface] = useState("__all__")
const [flowDedup, setFlowDedup] = useState(true)
const [flowExcludeMesh, setFlowExcludeMesh] = useState(true)
@@ -919,59 +924,46 @@ export default function TrafficPage() {
return () => { cancelled = true }
}, [apiFetch, isLive, groupMode, backendUrl, trafficRetry, range])
const loadFlows = useCallback(async () => {
if (!isLive) return
setLiveBusy(true)
setLiveError(null)
try {
const [stats, exporters, clients] = await Promise.all([
getTrafficFlows(backendUrl, range),
getFlowExporters(backendUrl, range),
getFlowClients(backendUrl, range),
])
setFlowStats(stats)
setFlowExporters(exporters.exporters)
setFlowClients(clients.clients)
setSelectedId((prev) => {
const list = flowScope === "users" ? clients.clients : exporters.exporters
if (list.some((x) => x.id === prev)) return prev
return list[0]?.id ?? ""
})
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
} finally {
setLiveBusy(false)
}
}, [isLive, backendUrl, range, flowScope])
useEffect(() => {
if (!isLive || effectiveMode !== "flows") return
void loadFlows()
}, [isLive, effectiveMode, loadFlows])
let cancelled = false
void Promise.all([
getTrafficFlows(backendUrl, range),
getFlowExporters(backendUrl, range),
getFlowClients(backendUrl, range),
]).then(([stats, exporters, clients]) => {
if (cancelled) return
setFlowResult({ key: flowKey, stats, exporters: exporters.exporters, clients: clients.clients })
setSelectedId(prev => {
const list = flowScope === "users" ? clients.clients : exporters.exporters
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
})
}).catch(e => {
if (!cancelled) setFlowResult({ key: flowKey, error: e instanceof Error ? e.message : "Не удалось загрузить потоки" })
})
return () => { cancelled = true }
}, [isLive, effectiveMode, backendUrl, range, flowScope, flowKey])
const analyticsKey = JSON.stringify([flowKey, selectedId, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, analyticsRetry])
const currentAnalytics = analyticsResult?.key === analyticsKey ? analyticsResult : null
const analyticsLoading = isLive && effectiveMode === "flows" && Boolean(selectedId) && !currentAnalytics
useEffect(() => {
if (!isLive || effectiveMode !== "flows" || !selectedId) {
setFlowAnalytics(null)
return
}
if (range === "30d") {
const month = new Date().toISOString().slice(0, 7)
void getFlowMonthly(backendUrl, {
month,
serverId: flowScope === "servers" ? selectedId : undefined,
}).then((m) => setFlowAnalytics(monthlyToAnalytics(m))).catch(() => setFlowAnalytics(null))
return
}
void getFlowAnalytics(backendUrl, {
range,
serverId: flowScope === "servers" ? selectedId : undefined,
userId: flowScope === "users" ? selectedId : undefined,
iface: flowIface,
dedup: flowDedup,
excludeMesh: flowExcludeMesh,
excludeOverlay: flowExcludeOverlay,
}).then(setFlowAnalytics).catch(() => setFlowAnalytics(null))
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl])
if (!isLive || effectiveMode !== "flows" || !selectedId) return
let cancelled = false
const request = range === "30d"
? getFlowMonthly(backendUrl, { month: new Date().toISOString().slice(0, 7), serverId: flowScope === "servers" ? selectedId : undefined }).then(monthlyToAnalytics)
: getFlowAnalytics(backendUrl, {
range, serverId: flowScope === "servers" ? selectedId : undefined,
userId: flowScope === "users" ? selectedId : undefined,
iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay,
})
void request.then(data => {
if (!cancelled) setAnalyticsResult({ key: analyticsKey, data })
}).catch(e => {
if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" })
})
return () => { cancelled = true }
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey])
useEffect(() => {
setFlowIface("__all__")
@@ -1110,7 +1102,7 @@ export default function TrafficPage() {
})
}, [flowCards, q, sortField, sortDir])
const selFlowCard = sortedFlowCards.find((c) => c.id === selectedId) ?? sortedFlowCards[0] ?? null
const displayedFlow = flowLiveSample ?? flowAnalytics
const displayedFlow = currentFlows && !liveError ? (flowLiveSample ?? currentAnalytics?.data ?? null) : null
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
const detailServer = isLive ? currentDetail?.server : selServer
@@ -1134,6 +1126,7 @@ export default function TrafficPage() {
|| flowLiveError
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
const flowLiveHint = flowLiveError ? (flowLiveSample ? "Последние данные · live недоступен" : "История · live недоступен") : flowLiveSample ? "live" : undefined
const flowKpiItems = [
{
id: "exporters",
@@ -1146,7 +1139,7 @@ export default function TrafficPage() {
id: "bps",
label: "Скорость",
value: displayedFlow ? fmtRate(displayedFlow.bpsNow / 1_000_000) : (flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—"),
hint: displayedFlow?.live ? "live" : undefined,
hint: flowLiveHint,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
@@ -1207,7 +1200,7 @@ export default function TrafficPage() {
variant="outline"
size="sm"
onClick={() => {
if (effectiveMode === "flows") void loadFlows()
if (effectiveMode === "flows") setFlowRetry(value => value + 1)
else setTrafficRetry(value => value + 1)
}}
disabled={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)}
@@ -1238,8 +1231,8 @@ export default function TrafficPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{(!isLive || effectiveMode === "flows" || !trafficError) && <KpiStatGrid
isLoading={isLive && effectiveMode !== "flows" && trafficLoading}
{(!isLive || !(effectiveMode === "flows" ? liveError : trafficError)) && <KpiStatGrid
isLoading={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)}
aria-label="Сводка трафика"
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
/>}
@@ -1255,7 +1248,9 @@ export default function TrafficPage() {
Подключить JH
</Button>
</div>
{flowError && (
{liveBusy && <p role="status">Загрузка потоков…</p>}
{liveError && <Alert variant="destructive" role="alert"><AlertTitle>Потоки недоступны</AlertTitle><AlertDescription>{liveError}<Button size="sm" variant="outline" onClick={() => setFlowRetry(value => value + 1)}>Повторить загрузку потоков</Button></AlertDescription></Alert>}
{flowError && !liveError && (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{flowError}
</div>
@@ -1325,7 +1320,7 @@ export default function TrafficPage() {
onClick={() => setSelectedId(card.id)}
/>
))}
{sortedFlowCards.length === 0 ? (
{!liveBusy && !liveError && sortedFlowCards.length === 0 ? (
<p className="text-xs text-muted-foreground">
{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
</p>
@@ -1334,6 +1329,8 @@ export default function TrafficPage() {
</div>
<Frame dense className="w-full flex flex-col">
<FramePanel className="flex-1 px-5 pb-5 pt-5">
{analyticsLoading && <p role="status">Загрузка аналитики…</p>}
{currentAnalytics?.error && <Alert variant="destructive" role="alert"><AlertTitle>Аналитика недоступна</AlertTitle><AlertDescription>{currentAnalytics.error}<Button size="sm" variant="outline" onClick={() => setAnalyticsRetry(value => value + 1)}>Повторить аналитику</Button></AlertDescription></Alert>}
<FlowAnalyticsDetail
card={selFlowCard}
analytics={displayedFlow}
@@ -1347,8 +1344,8 @@ export default function TrafficPage() {
onExcludeMesh={setFlowExcludeMesh}
excludeOverlay={flowExcludeOverlay}
onExcludeOverlay={setFlowExcludeOverlay}
liveHint={displayedFlow?.live ? "live" : undefined}
emptyHint={flowEmptyHint(flowStats, collectorAlive)}
liveHint={flowLiveHint}
emptyHint={analyticsLoading ? "Загрузка аналитики…" : currentAnalytics?.error ? "Не удалось получить данные выбранного среза" : flowEmptyHint(flowStats, collectorAlive)}
/>
</FramePanel>
</Frame>
@@ -1358,7 +1355,7 @@ export default function TrafficPage() {
onOpenChange={setOverlayOpen}
servers={catalogServers}
backendUrl={backendUrl}
onDone={() => { void loadFlows() }}
onDone={() => { setFlowRetry(value => value + 1) }}
/>
</div>
) : (