fix(traffic): сохранять историю и показывать достоверную свежесть

This commit is contained in:
Denozordec committed 2026-10-12 20:23:07 +07:00
1 parent ebe62ae2ef
commit 1275dd7b9b
48 files changed
+1899 -187

No files matched your search

+89 -77
View File
@@ -2,6 +2,8 @@
import { useState, useMemo, useEffect, type ReactNode } from "react"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { projectTrafficObservations, type TrafficObservationMeta } from "@/lib/traffic-observations"
import { TrafficObservationStatus } from "@/components/traffic/traffic-observation-status"
import { TrafficDataTime } from "@/components/traffic/traffic-data-time"
import { PageHeader } from "@/components/page-header"
import { Frame, FramePanel } from "@/components/reui/frame"
@@ -84,6 +86,7 @@ function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): st
// ─── data model ───────────────────────────────────────────────────────────────
interface BoundIfaceTraffic {
observations?: TrafficObservationMeta
id: string
bindingId: string
userId: string
@@ -98,75 +101,79 @@ interface BoundIfaceTraffic {
serverName: string
serverSite: string
serverCountry: string
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxSeries: number[]
txSeries: number[]
rxNow: number | null
txNow: number | null
rxPeak: number | null
txPeak: number | null
rxTotal: number | null
txTotal: number | null
rxSeries: (number|null)[]
txSeries: (number|null)[]
status: "online" | "offline"
}
interface ServerTraffic {
observations?: TrafficObservationMeta
id: string
name: string
site: string
country: string
status: "online" | "offline" | "degraded"
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxNow: number | null
txNow: number | null
rxPeak: number | null
txPeak: number | null
rxTotal: number | null
txTotal: number | null
sessions: number
rxSeries: number[]
txSeries: number[]
rxSeries: (number|null)[]
txSeries: (number|null)[]
boundIfaces: BoundIfaceTraffic[]
}
interface UserTraffic {
observations?: TrafficObservationMeta
id: string
login: string
displayName: string
role: string
active?: boolean
interfaces: BoundIfaceTraffic[]
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxSeries: number[]
txSeries: number[]
rxNow: number | null
txNow: number | null
rxPeak: number | null
txPeak: number | null
rxTotal: number | null
txTotal: number | null
rxSeries: (number|null)[]
txSeries: (number|null)[]
}
interface LiveTrafficServer {
observations?: TrafficObservationMeta
id: string
name: string
site: string
country: string
status: "online" | "offline" | "degraded"
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxNow: number | null
txNow: number | null
rxPeak: number | null
txPeak: number | null
rxTotal: number | null
txTotal: number | null
sessions: number
rxSeries: number[]
txSeries: number[]
rxSeries: (number|null)[]
txSeries: (number|null)[]
}
interface LiveTrafficInterface {
observations?: TrafficObservationMeta
name: string
running: boolean
disabled: boolean
rxNow: number
txNow: number
rxNow: number | null
txNow: number | null
}
function makeApiFetch(backendUrl: string) {
@@ -175,6 +182,13 @@ function makeApiFetch(backendUrl: string) {
}
}
function compareRates(a: number|null, b: number|null, direction: "asc"|"desc") {
if (a === null && b === null) return 0
if (a === null) return direction === "desc" ? -1 : 1
if (b === null) return direction === "desc" ? 1 : -1
return a - b
}
function hashSeed(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
@@ -278,14 +292,14 @@ function buildUserTraffic(
clients: BoundIfaceTraffic[],
active = true,
): UserTraffic {
const rxNow = clients.reduce((a, c) => a + c.rxNow, 0)
const txNow = clients.reduce((a, c) => a + c.txNow, 0)
const rxPeak = clients.reduce((a, c) => a + c.rxPeak, 0)
const txPeak = clients.reduce((a, c) => a + c.txPeak, 0)
const rxTotal = clients.reduce((a, c) => a + c.rxTotal, 0)
const txTotal = clients.reduce((a, c) => a + c.txTotal, 0)
const rxSeries = clients.reduce((a, c) => addSeries(a, c.rxSeries), Array(60).fill(0) as number[])
const txSeries = clients.reduce((a, c) => addSeries(a, c.txSeries), Array(60).fill(0) as number[])
const rxNow = clients.reduce((a, c) => a + (c.rxNow ?? 0), 0)
const txNow = clients.reduce((a, c) => a + (c.txNow ?? 0), 0)
const rxPeak = clients.reduce((a, c) => a + (c.rxPeak ?? 0), 0)
const txPeak = clients.reduce((a, c) => a + (c.txPeak ?? 0), 0)
const rxTotal = clients.reduce((a, c) => a + (c.rxTotal ?? 0), 0)
const txTotal = clients.reduce((a, c) => a + (c.txTotal ?? 0), 0)
const rxSeries = clients.reduce((a, c) => addSeries(a, c.rxSeries.map(v => v ?? 0)), Array(60).fill(0) as number[])
const txSeries = clients.reduce((a, c) => addSeries(a, c.txSeries.map(v => v ?? 0)), Array(60).fill(0) as number[])
return { id, login, displayName, role, active, interfaces: clients, rxNow, txNow, rxPeak, txPeak, rxTotal, txTotal, rxSeries, txSeries }
}
@@ -538,21 +552,21 @@ function OfflinePlaceholder({ text = "Нет данных — объект не
}
function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
rxTotal: number; txTotal: number; rxSeries: number[]; txSeries: number[]
rxTotal: number|null; txTotal: number|null; rxSeries: (number|null)[]; txSeries: (number|null)[]
}) {
return (
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4 [&>div]:min-w-0 [&_svg]:max-w-full">
<div>
<p className="text-xs text-muted-foreground mb-1">Получено за период</p>
<p className="text-xs text-muted-foreground mb-1">Учтено RX по замерам</p>
<p className="text-lg font-semibold tabular-nums">
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
{fmtGB(rxTotal)}
</p>
<Sparkline data={rxSeries} width={180} height={28} color="var(--chart-rx)" filled />
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Отправлено за период</p>
<p className="text-xs text-muted-foreground mb-1">Учтено TX по замерам</p>
<p className="text-lg font-semibold tabular-nums">
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
{fmtGB(txTotal)}
</p>
<Sparkline data={txSeries} width={180} height={28} color="var(--chart-tx)" filled />
</div>
@@ -583,7 +597,8 @@ function ServerDetail({
<Flag code={sel.country} />{sel.site}
</span>
</DetailHeader>
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
<TrafficObservationStatus observations={sel.observations} unknown={sel.rxNow == null || sel.txNow == null} />
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
<div className="mt-4 pt-4 border-t">
<KpiStatGrid
aria-label="Скорость выбранного сервера"
@@ -689,7 +704,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
<Flag code={sel.serverCountry} />{sel.serverSite}
</span>
</DetailHeader>
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX" value={fmtRate(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX" value={fmtRate(sel.txNow)} />
@@ -738,24 +753,16 @@ const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
site: s.site || "—",
country: s.country || "UN",
status: s.status,
rxNow: s.rxNow,
txNow: s.txNow,
rxPeak: s.rxPeak,
txPeak: s.txPeak,
rxTotal: s.rxTotal,
txTotal: s.txTotal,
sessions: s.sessions,
rxSeries: s.rxSeries,
txSeries: s.txSeries,
boundIfaces: [],
...projectTrafficObservations(s),
}
}
const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({
...c,
interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType,
rxSeries: c.rxSeries,
txSeries: c.txSeries,
...projectTrafficObservations(c),
})
const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => {
@@ -764,14 +771,19 @@ const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): User
...u,
role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role,
interfaces: ifaces,
rxSeries: u.rxSeries,
txSeries: u.txSeries,
...projectTrafficObservations(u),
}
}
export default function TrafficPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [trafficClock, setTrafficClock] = useState(() => Date.now())
useEffect(() => {
if (!isLive) return
const timer = setInterval(() => setTrafficClock(Date.now()), 10_000)
return () => clearInterval(timer)
}, [isLive])
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
@@ -945,9 +957,9 @@ export default function TrafficPage() {
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
}, [isLive, backendUrl])
const activeServerTraffic = useMemo(() => isLive ? (trafficReady ? liveServers : []) : serverTraffic, [isLive, trafficReady, liveServers])
const activeUserTraffic = useMemo(() => isLive ? (trafficReady ? liveUsers : []) : userTraffic, [isLive, trafficReady, liveUsers])
const activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces])
const activeServerTraffic = useMemo(() => isLive ? (trafficReady ? liveServers.map(s => ({ ...s, ...projectTrafficObservations(s, trafficClock) })) : []) : serverTraffic, [isLive, trafficReady, liveServers, trafficClock])
const activeUserTraffic = useMemo(() => isLive ? (trafficReady ? liveUsers.map(u => ({ ...u, ...projectTrafficObservations(u, trafficClock), interfaces: u.interfaces.map(toLiveBound) })) : []) : userTraffic, [isLive, trafficReady, liveUsers, trafficClock])
const activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces.map(c => ({ ...c, ...projectTrafficObservations(c, trafficClock) })) : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces, trafficClock])
const visibleIfaces = useMemo(
() => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces,
[serverIfaces, hideDisabledIfaces],
@@ -1022,8 +1034,8 @@ export default function TrafficPage() {
.filter(s => !q || s.name.toLowerCase().includes(q) || s.site.toLowerCase().includes(q))
.sort((a, b) => {
let v = 0
if (sortField === "rx") v = a.rxNow - b.rxNow
else if (sortField === "tx") v = a.txNow - b.txNow
if (sortField === "rx") v = compareRates(a.rxNow, b.rxNow, sortDir)
else if (sortField === "tx") v = compareRates(a.txNow, b.txNow, sortDir)
else if (sortField === "name") v = a.name.localeCompare(b.name)
else if (sortField === "sessions") v = a.sessions - b.sessions
return sortDir === "desc" ? -v : v
@@ -1035,8 +1047,8 @@ export default function TrafficPage() {
.filter(u => !q || u.login.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q))
.sort((a, b) => {
let v = 0
if (sortField === "rx") v = a.rxNow - b.rxNow
else if (sortField === "tx") v = a.txNow - b.txNow
if (sortField === "rx") v = compareRates(a.rxNow, b.rxNow, sortDir)
else if (sortField === "tx") v = compareRates(a.txNow, b.txNow, sortDir)
else if (sortField === "name") v = a.login.localeCompare(b.login)
else if (sortField === "sessions") v = a.interfaces.length - b.interfaces.length
return sortDir === "desc" ? -v : v
@@ -1052,8 +1064,8 @@ export default function TrafficPage() {
|| c.userLogin.toLowerCase().includes(q))
.sort((a, b) => {
let v = 0
if (sortField === "rx") v = a.rxNow - b.rxNow
else if (sortField === "tx") v = a.txNow - b.txNow
if (sortField === "rx") v = compareRates(a.rxNow, b.rxNow, sortDir)
else if (sortField === "tx") v = compareRates(a.txNow, b.txNow, sortDir)
else if (sortField === "name") v = a.interfaceName.localeCompare(b.interfaceName)
return sortDir === "desc" ? -v : v
})
@@ -1065,8 +1077,8 @@ export default function TrafficPage() {
.filter((c) => !q || c.name.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q) || c.site.toLowerCase().includes(q))
.sort((a, b) => {
let v = 0
if (sortField === "rx") v = a.rxNow - b.rxNow
else if (sortField === "tx") v = a.txNow - b.txNow
if (sortField === "rx") v = compareRates(a.rxNow, b.rxNow, sortDir)
else if (sortField === "tx") v = compareRates(a.txNow, b.txNow, sortDir)
else if (sortField === "name") v = a.name.localeCompare(b.name)
else if (sortField === "sessions") v = a.sessions - b.sessions
return sortDir === "desc" ? -v : v
@@ -1081,7 +1093,7 @@ export default function TrafficPage() {
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
const detailServer = isLive ? (currentDetail?.server ? { ...currentDetail.server, ...projectTrafficObservations(currentDetail.server, trafficClock) } : null) : selServer
const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic])
const selIface = useMemo(() => activeBoundIfaces.find(c => c.id === selectedId) ?? activeBoundIfaces[0], [selectedId, activeBoundIfaces])
@@ -1090,10 +1102,10 @@ export default function TrafficPage() {
: effectiveMode === "ifaces"
? activeBoundIfaces
: activeServerTraffic
const totalRx = kpiSource.reduce((a, s) => a + s.rxNow, 0)
const totalTx = kpiSource.reduce((a, s) => a + s.txNow, 0)
const peakRx = kpiSource.reduce((a, s) => Math.max(a, s.rxPeak), 0)
const peakTx = kpiSource.reduce((a, s) => Math.max(a, s.txPeak), 0)
const totalRx = kpiSource.every(s => s.rxNow !== null) ? kpiSource.reduce((a, s) => a + (s.rxNow ?? 0), 0) : null
const totalTx = kpiSource.every(s => s.txNow !== null) ? kpiSource.reduce((a, s) => a + (s.txNow ?? 0), 0) : null
const peakRx = kpiSource.every(s => s.rxPeak !== null) ? kpiSource.reduce((a, s) => Math.max(a, s.rxPeak ?? 0), 0) : null
const peakTx = kpiSource.every(s => s.txPeak !== null) ? kpiSource.reduce((a, s) => Math.max(a, s.txPeak ?? 0), 0) : null
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
const ingestLine = flowIngestLine(flowStats)