fix(traffic): сохранять историю и показывать достоверную свежесть
This commit is contained in:
1 parent
ebe62ae2ef
commit
1275dd7b9b
48 files changed
+1899
-187
No files matched your search
+89
-77
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user