fix(ui): привязать flow-аналитику к контексту запроса
This commit is contained in:
1 parent
94dd453b33
commit
24313340d5
5 files changed
+163
-82
No files matched your search
+63
-66
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user