"use client" 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 { projectTrafficBindingStatus } from "@/lib/traffic-binding-status" 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" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { TrafficMiniChart } from "@/components/reui-kit/traffic-mini-chart" import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart" import { Button } from "@/components/ui/button" import { Sparkline } from "@/components/sparkline" import { StatusDot } from "@/components/status-dot" import { Flag } from "@/components/flag" import { RefreshCwIcon, TrendingUpIcon, TrendingDownIcon, ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, GitBranchIcon, PlusIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { fmtGiB, fmtRate } from "@/lib/fmt-rate" import { useDataSource } from "@/lib/data-source" import { useTrafficLive } from "@/hooks/use-traffic-live" import { useFlowLive } from "@/hooks/use-flow-live" import { requestJson } from "@/shared/api/http-client" import { getFlowAnalytics, getFlowClients, getFlowExporters, getFlowMonthly, getTrafficFlows } from "@/shared/api/traffic-flow" import { listServers } from "@/shared/api/servers" import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet" import { FlowAnalyticsDetail, FlowEntityCardView } from "@/components/traffic/flow-analytics-panel" import type { FlowAnalyticsDto, FlowEntityCard, FlowMonthlyDto, FlowStatsDto } from "@mmapp/contracts/traffic-flow" import type { ServerRead } from "@mmapp/contracts/servers" import { Badge } from "@/components/reui/badge" import { IFACE_TYPE_LABEL, INIT_USERS, ROLE_LABEL, type InterfaceType, } from "@/lib/users" import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail" // ─── helpers ────────────────────────────────────────────────────────────────── function seededRand(seed: number) { let s = seed return () => { s = (s * 9301 + 49297) % 233280; return s / 233280 } } function buildSeries(seed: number, base: number, range: number, n = 60): number[] { const rand = seededRand(seed) return Array.from({ length: n }, () => Math.max(0, Math.round(base + (rand() - 0.5) * range * 2))) } function addSeries(a: number[], b: number[]): number[] { return a.map((v, i) => v + (b[i] ?? 0)) } function flowIngestLine(stats: FlowStatsDto | null): string | null { if (!stats) return null const listener = stats.listenerBound ? (stats.listenerAddress ?? "слушает") : "не слушает" const last = stats.lastDatagramAt ? new Date(stats.lastDatagramAt).toLocaleString("ru-RU") : "—" const exporter = stats.lastExporterIp ? ` · ${stats.lastExporterIp}` : "" const err = stats.lastError ? ` · ${stats.lastError}` : "" return `Коллектор: ${listener} · пакеты ${stats.packetsReceived ?? 0} · последний ${last}${exporter}${err}` } function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): string | undefined { if (!stats) return undefined if (stats.lastError) return stats.lastError if (stats.packetsReceived) { return `IPFIX приходит (${stats.lastExporterIp ?? "экспортёр"}), но сессии ещё не записаны.` } if (stats.listenerBound === false && !collectorAlive) { return "Коллектор UDP не слушает. Подключите JH ещё раз — ingest включится автоматически." } if (stats.listenerBound || collectorAlive) { return "Коллектор жив, IPFIX ещё не доходит. На jump-host у target Src должен быть 0.0.0.0 (авто)." } return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow." } // ─── data model ─────────────────────────────────────────────────────────────── interface BoundIfaceTraffic { statusObservedAt?: string|null statusExpiresAt?: string|null volumeUnit?: "GiB" observations?: TrafficObservationMeta id: string bindingId: string userId: string userLogin: string userName: string interfaceName: string interfaceType: InterfaceType peerPublicKey?: string peerName?: string comment: string serverId: string serverName: string serverSite: string serverCountry: string 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" | "unknown" } interface ServerTraffic { volumeUnit?: "GiB" observations?: TrafficObservationMeta id: string name: string site: string country: string status: "online" | "offline" | "degraded" rxNow: number | null txNow: number | null rxPeak: number | null txPeak: number | null rxTotal: number | null txTotal: number | null sessions: number rxSeries: (number|null)[] txSeries: (number|null)[] boundIfaces: BoundIfaceTraffic[] } interface UserTraffic { volumeUnit?: "GiB" observations?: TrafficObservationMeta id: string login: string displayName: string role: string active?: boolean interfaces: BoundIfaceTraffic[] 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 { volumeUnit?: "GiB" observations?: TrafficObservationMeta id: string name: string site: string country: string status: "online" | "offline" | "degraded" rxNow: number | null txNow: number | null rxPeak: number | null txPeak: number | null rxTotal: number | null txTotal: number | null sessions: number rxSeries: (number|null)[] txSeries: (number|null)[] } interface LiveTrafficInterface { observations?: TrafficObservationMeta name: string running: boolean disabled: boolean rxNow: number | null txNow: number | null } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } 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 return Math.abs(h) } function boundIfaceLabel(c: Pick): string { if (c.interfaceType === "wg" && c.peerName) return `${c.peerName} · ${c.interfaceName}` return c.interfaceName } function mockBoundFromUsers(): BoundIfaceTraffic[] { return INIT_USERS.flatMap((u) => u.bindings.map((b) => { const seed = hashSeed(b.id) const offline = !u.active || b.interfaceName.includes("retail") || b.interfaceName.includes("warehouse") const rxNow = offline ? 0 : 12 + (seed % 140) const txNow = offline ? 0 : 8 + (seed % 110) return { id: `${b.userId}:${b.serverId}:${b.interfaceName}:${b.peerPublicKey ?? "_iface"}`, bindingId: b.id, userId: u.id, userLogin: u.login, userName: u.name, interfaceName: b.interfaceName, interfaceType: b.interfaceType, peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment, serverId: b.serverId, serverName: b.serverName, serverSite: b.serverSite, serverCountry: b.serverCountry, rxNow, txNow, rxPeak: rxNow + 40, txPeak: txNow + 28, rxTotal: offline ? 0 : +(rxNow / 8).toFixed(1), txTotal: offline ? 0 : +(txNow / 10).toFixed(1), rxSeries: offline ? Array(60).fill(0) : buildSeries(seed, rxNow, Math.max(4, rxNow / 4)), txSeries: offline ? Array(60).fill(0) : buildSeries(seed + 17, txNow, Math.max(3, txNow / 4)), status: (offline ? "offline" : "online") as "online" | "offline", } }), ) } const boundIfaces: BoundIfaceTraffic[] = mockBoundFromUsers() // ─── mock server data (with bound interfaces attached) ──────────────────────── const serverTraffic: ServerTraffic[] = [ { id: "srv1", name: "mt-msk-core-01", site: "MSK", country: "RU", status: "online", rxNow: 342, txNow: 287, rxPeak: 614, txPeak: 521, rxTotal: 124.8, txTotal: 98.2, sessions: 4, rxSeries: buildSeries(101, 342, 80), txSeries: buildSeries(201, 287, 70), boundIfaces: boundIfaces.filter((c) => c.serverId === "srv1"), }, { id: "srv2", name: "mt-spb-edge-01", site: "SPB", country: "RU", status: "online", rxNow: 218, txNow: 164, rxPeak: 412, txPeak: 310, rxTotal: 78.4, txTotal: 61.1, sessions: 6, rxSeries: buildSeries(102, 218, 60), txSeries: buildSeries(202, 164, 50), boundIfaces: [], }, { id: "srv3", name: "mt-fra-edge-01", site: "FRA", country: "DE", status: "online", rxNow: 184, txNow: 141, rxPeak: 388, txPeak: 282, rxTotal: 66.2, txTotal: 51.4, sessions: 3, rxSeries: buildSeries(103, 184, 55), txSeries: buildSeries(203, 141, 45), boundIfaces: [], }, { id: "srv4", name: "mt-ams-edge-01", site: "AMS", country: "NL", status: "degraded", rxNow: 88, txNow: 61, rxPeak: 244, txPeak: 188, rxTotal: 32.1, txTotal: 24.8, sessions: 2, rxSeries: buildSeries(104, 88, 40), txSeries: buildSeries(204, 61, 30), boundIfaces: [], }, { id: "srv5", name: "mt-sgp-edge-01", site: "SGP", country: "SG", status: "offline", rxNow: 0, txNow: 0, rxPeak: 0, txPeak: 0, rxTotal: 0, txTotal: 0, sessions: 0, rxSeries: Array(60).fill(0), txSeries: Array(60).fill(0), boundIfaces: [], }, { id: "srv6", name: "mt-ams-test-01", site: "AMS", country: "NL", status: "online", rxNow: 42, txNow: 28, rxPeak: 118, txPeak: 84, rxTotal: 14.2, txTotal: 9.8, sessions: 1, rxSeries: buildSeries(105, 42, 20), txSeries: buildSeries(205, 28, 14), boundIfaces: [], }, { id: "srv7", name: "mt-msk-lab-01", site: "MSK", country: "RU", status: "online", rxNow: 12, txNow: 8, rxPeak: 44, txPeak: 31, rxTotal: 4.2, txTotal: 2.9, sessions: 2, rxSeries: buildSeries(106, 12, 6), txSeries: buildSeries(206, 8, 4), boundIfaces: boundIfaces.filter(c => c.serverId === "srv7"), }, ] // ─── user traffic (aggregated from bound interfaces) ────────────────────────── function buildUserTraffic( id: string, login: string, displayName: string, role: string, clients: BoundIfaceTraffic[], active = true, ): UserTraffic { 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 } } const userTraffic: UserTraffic[] = INIT_USERS.map((u) => buildUserTraffic( u.id, u.login, u.name, ROLE_LABEL[u.role], boundIfaces.filter((c) => c.userId === u.id), u.active, ), ) // ─── types ──────────────────────────────────────────────────────────────────── /** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */ const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const type Range = (typeof TRAFFIC_RANGE_KEYS)[number] | "30d" const TRAFFIC_RANGE_LABELS: Record<(typeof TRAFFIC_RANGE_KEYS)[number], string> = { "5m": "5м", "15m": "15м", "1h": "1ч", "4h": "4ч", "24h": "24ч", } type GroupMode = "servers" | "users" | "ifaces" | "flows" type FlowScope = "servers" | "users" type SortField = "rx" | "tx" | "name" | "sessions" type SortDir = "asc" | "desc" // ─── shared sub-components ──────────────────────────────────────────────────── function StatChip({ icon, label, value }: { icon: ReactNode; label: string; value: string }) { return (
{icon}

{label}

{value}

) } // ─── left-panel entity cards ────────────────────────────────────────────────── function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: boolean; onClick: () => void }) { return ( ) } function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean; onClick: () => void }) { const hasClients = u.interfaces.length > 0 return ( ) } function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: boolean; onClick: () => void }) { return ( ) } // ─── GRE client row (replaces bulky grid tables) ───────────────────────────── /** * Compact single-row card for a GRE client. * showServer=true adds a server chip (used in user-detail where server is not implied). */ function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?: boolean }) { return (
{boundIfaceLabel(c)} {IFACE_TYPE_LABEL[c.interfaceType]} {showServer && ( {c.serverSite} )}

{c.comment || c.userLogin}

{fmtRate(c.rxNow)}
{fmtRate(c.txNow)}

{fmtGiB(c.rxTotal)}

{fmtGiB(c.txTotal)}

{c.status === "online" ? "Работает" : c.status === "unknown" ? "Нет данных" : "Неактивен"}
) } function BoundIfacesList({ clients, showServer = false }: { clients: BoundIfaceTraffic[]; showServer?: boolean }) { if (clients.length === 0) return

Нет привязанных интерфейсов

return (
{clients.map((c) => )}
) } // ─── detail panel helpers ───────────────────────────────────────────────────── function DetailHeader({ range, setRange, children }: { range: Range; setRange: (r: Range) => void; children: ReactNode }) { return (
{children}
{TRAFFIC_RANGE_KEYS.map((r) => ( ))}
) } function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) { return (

{text}

) } function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: { rxTotal: number|null; txTotal: number|null; rxSeries: (number|null)[]; txSeries: (number|null)[] }) { return (

Учтено RX по замерам

{fmtGiB(rxTotal)}

Учтено TX по замерам

{fmtGiB(txTotal)}

) } // ─── detail panels ──────────────────────────────────────────────────────────── function ServerDetail({ sel, range, setRange, liveRx, liveTx, liveHint, }: { sel: ServerTraffic range: Range setRange: (r: Range) => void liveRx?: number liveTx?: number liveHint?: string }) { const rxNow = liveRx ?? sel.rxNow const txNow = liveTx ?? sel.txNow return ( <>

{sel.name}

{sel.site}
, iconClassName: "text-success", }, { id: "tx-now", label: "TX", value: fmtRate(txNow), hint: liveHint, icon: , iconClassName: "text-info", }, { id: "rx-peak", label: "Пик RX", value: fmtRate(sel.rxPeak), icon: , iconClassName: "text-warning", }, { id: "tx-peak", label: "Пик TX", value: fmtRate(sel.txPeak), icon: , iconClassName: "text-warning", }, ]} />
{sel.boundIfaces.length > 0 && (

Привязанные интерфейсы ({sel.boundIfaces.length})

)} ) } function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range; setRange: (r: Range) => void }) { const hasClients = sel.interfaces.length > 0 return ( <>

{sel.login}

{sel.displayName} · {sel.role}

{!hasClients ? ( ) : ( <>
} label="RX" value={fmtRate(sel.rxNow)} /> } label="TX" value={fmtRate(sel.txNow)} /> } label="Пик RX" value={fmtRate(sel.rxPeak)} /> } label="Интерфейсы" value={`${sel.interfaces.length}`} />

Привязанные интерфейсы ({sel.interfaces.length})

)} ) } function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range: Range; setRange: (r: Range) => void }) { return ( <>

{boundIfaceLabel(sel)}

{sel.comment || sel.userLogin}

{IFACE_TYPE_LABEL[sel.interfaceType]} {sel.serverSite}
} label="RX" value={fmtRate(sel.rxNow)} /> } label="TX" value={fmtRate(sel.txNow)} /> } label="Пик RX" value={fmtRate(sel.rxPeak)} /> } label="Пик TX" value={fmtRate(sel.txPeak)} />

Сервер

{sel.serverName}

Тип

{IFACE_TYPE_LABEL[sel.interfaceType]}

Пользователь

{sel.userLogin}

) } // ─── page ───────────────────────────────────────────────────────────────────── const GROUP_MODES: Array<{ mode: GroupMode; icon: ReactNode; label: string }> = [ { mode: "servers", icon: , label: "Серверы" }, { mode: "users", icon: , label: "Клиенты" }, { mode: "ifaces", icon: , label: "Интерфейсы" }, { mode: "flows", icon: , label: "Потоки" }, ] const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMode[] }> = [ { field: "rx", label: "RX" }, { field: "tx", label: "TX" }, { field: "name", label: "Имя" }, { field: "sessions", label: "Сессий", modesOnly: ["servers", "users", "flows"] }, ] const toLiveServer = (s: LiveTrafficServer): ServerTraffic => { return { id: String(s.id), name: s.name, site: s.site || "—", country: s.country || "UN", status: s.status, sessions: s.sessions, boundIfaces: [], ...projectTrafficObservations(s), } } const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({ ...c, interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType, ...projectTrafficObservations(c), status: projectTrafficBindingStatus(c.status,c.statusObservedAt,c.statusExpiresAt), }) const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => { const ifaces = (u.interfaces ?? []).map(toLiveBound) return { ...u, role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role, interfaces: ifaces, ...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("servers") const [sortField, setSortField] = useState("rx") const [sortDir, setSortDir] = useState("desc") const [selectedId, setSelectedId] = useState("srv1") const [range, setRange] = useState("1h") const [search, setSearch] = useState("") const [trafficRetry, setTrafficRetry] = useState(0) const [liveRetry, setLiveRetry] = useState(0) const trafficKey = JSON.stringify([isLive, backendUrl, range, groupMode, trafficRetry]) const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; receivedAt?: number; error?: string } | null>(null) const trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready" const trafficLoading = trafficState?.key !== trafficKey const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined const [liveServers, setLiveServers] = useState([]) const trafficSourceKey = JSON.stringify([isLive, backendUrl]) const [liveServerOwner, setLiveServerOwner] = useState(null) const confirmedLiveServer = isLive && liveServerOwner === trafficSourceKey && /^[1-9]\d*$/.test(selectedId) && Number.isSafeInteger(Number(selectedId)) && liveServers.some(server => server.id === selectedId) const [serverIfaces, setServerIfaces] = useState([]) const [selectedIface, setSelectedIface] = useState("__all__") const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true) const [detailRetry, setDetailRetry] = useState(0) const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; receivedAt?: number; error: string | null } | null>(null) const [liveUsers, setLiveUsers] = useState([]) const [liveBoundIfaces, setLiveBoundIfaces] = useState([]) const [flowScope, setFlowScope] = useState("servers") const [flowRetry, setFlowRetry] = useState(0) const flowKey = JSON.stringify([isLive, groupMode, backendUrl, range, flowScope, flowRetry]) const [flowResult, setFlowResult] = useState<{ key: string; receivedAt?: number; monthlyServers?: ServerRead[]; 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 [flowMonth, setFlowMonth] = useState(() => new Date().toISOString().slice(0, 7)) const flowMonthValid = /^(?!0000)\d{4}-(0[1-9]|1[0-2])$/.test(flowMonth) && flowMonth <= new Date().toISOString().slice(0, 7) const [analyticsRetry, setAnalyticsRetry] = useState(0) const [analyticsResult, setAnalyticsResult] = useState<{ key: string; receivedAt?: number; data?: FlowAnalyticsDto; monthly?: FlowMonthlyDto; error?: string } | null>(null) const flowIfaceOwner = JSON.stringify([isLive, backendUrl, groupMode, flowScope, selectedId]) const [flowIfaceSelection, setFlowIfaceSelection] = useState<{ owner: string; iface: string } | null>(null) const flowIface = flowIfaceSelection?.owner === flowIfaceOwner ? flowIfaceSelection.iface : "__all__" const setFlowIface = (iface: string) => setFlowIfaceSelection({ owner: flowIfaceOwner, iface }) const [flowDedup, setFlowDedup] = useState(true) const [flowExcludeMesh, setFlowExcludeMesh] = useState(true) const [flowExcludeOverlay, setFlowExcludeOverlay] = useState(true) const [overlayOpen, setOverlayOpen] = useState(false) const [catalogServers, setCatalogServers] = useState([]) const effectiveMode: GroupMode = groupMode const detailKey = JSON.stringify([isLive, backendUrl, effectiveMode, selectedId, range, selectedIface, detailRetry]) const currentDetail = detailResult?.key === detailKey ? detailResult : null const detailBusy = isLive && effectiveMode === "servers" && Boolean(selectedId) && currentDetail === null const { sample: liveSample, error: liveStreamError, receivedAt: liveReceivedAt } = useTrafficLive({ enabled: confirmedLiveServer && effectiveMode === "servers", backendUrl, serverId: selectedId, iface: selectedIface, reloadKey: liveRetry, }) const confirmedFlowSelection = isLive && Boolean(currentFlows) && !liveError && ( range === "30d" ? /^[1-9]\d*$/.test(selectedId) && Number.isSafeInteger(Number(selectedId)) && Boolean(currentFlows?.monthlyServers?.some(server => String(server.id) === selectedId)) : flowScope === "servers" ? /^[1-9]\d*$/.test(selectedId) && Number.isSafeInteger(Number(selectedId)) && flowExporters.some(card => card.id === selectedId) : Boolean(selectedId) && flowClients.some(card => card.id === selectedId) ) const flowLiveEnabled = confirmedFlowSelection && effectiveMode === "flows" && range === "5m" const { sample: flowLiveSample, error: flowLiveError, receivedAt: flowLiveReceivedAt } = useFlowLive({ enabled: flowLiveEnabled, backendUrl, range, serverId: flowScope === "servers" ? selectedId : undefined, userId: flowScope === "users" ? selectedId : undefined, iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay, }) useEffect(() => { if (!isLive || groupMode === "flows") return let cancelled = false const loadLiveTraffic = async (targetRange: Range) => { const key = JSON.stringify([isLive, backendUrl, targetRange, groupMode, trafficRetry]) try { const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange) const [srvRes, usersRes, ifacesRes] = await Promise.all([ apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`), apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`), apiFetch<{ interfaces: BoundIfaceTraffic[] }>(`/api/traffic/bound-interfaces?range=${q}`), ]) if (cancelled) return const liveIfaces = ifacesRes.interfaces.map(toLiveBound) const mapped: ServerTraffic[] = srvRes.servers.map((s) => { const base = toLiveServer(s) return { ...base, boundIfaces: liveIfaces.filter((i) => i.serverId === String(s.id)) } }) const mappedUsers = usersRes.users.map(toLiveUser) setLiveServers(mapped) setLiveServerOwner(JSON.stringify([isLive, backendUrl])) setLiveUsers(mappedUsers) setLiveBoundIfaces(liveIfaces) setTrafficState({ key, status: "ready", receivedAt: Date.now() }) setSelectedId((prev) => { if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "") if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "") return mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? "") }) } catch (e) { if (!cancelled) setTrafficState({ key, status: "error", error: e instanceof Error ? e.message : "Не удалось загрузить трафик" }) } } void loadLiveTraffic(range) return () => { cancelled = true } }, [apiFetch, isLive, groupMode, backendUrl, trafficRetry, range]) useEffect(() => { if (!isLive || effectiveMode !== "flows") return let cancelled = false if (range === "30d") { void listServers(backendUrl).then(servers => { if (cancelled) return setFlowResult({ key: flowKey, receivedAt: Date.now(), monthlyServers: servers }) setSelectedId(prev => servers.some(server => String(server.id) === prev) ? prev : String(servers[0]?.id ?? "")) }).catch(error => { if (!cancelled) setFlowResult({ key: flowKey, error: error instanceof Error ? error.message : "Не удалось загрузить каталог серверов" }) }) return () => { cancelled = true } } void Promise.all([ getTrafficFlows(backendUrl, range), getFlowExporters(backendUrl, range), getFlowClients(backendUrl, range), ]).then(([stats, exporters, clients]) => { if (cancelled) return setFlowResult({ key: flowKey, receivedAt: Date.now(), 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, range === "30d" ? flowMonth : null]) const currentAnalytics = analyticsResult?.key === analyticsKey ? analyticsResult : null const analyticsLoading = confirmedFlowSelection && effectiveMode === "flows" && (range !== "30d" || flowMonthValid) && !currentAnalytics useEffect(() => { if (!confirmedFlowSelection || effectiveMode !== "flows" || (range === "30d" && !flowMonthValid)) return let cancelled = false const request = range === "30d" ? getFlowMonthly(backendUrl, { month: flowMonth, serverId: flowScope === "servers" ? selectedId : undefined }).then(monthly => ({ monthly })) : getFlowAnalytics(backendUrl, { range, serverId: flowScope === "servers" ? selectedId : undefined, userId: flowScope === "users" ? selectedId : undefined, iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay, }).then(data => ({ data })) void request.then(result => { if (!cancelled) setAnalyticsResult({ key: analyticsKey, receivedAt: Date.now(), ...result }) }).catch(e => { if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" }) }) return () => { cancelled = true } }, [confirmedFlowSelection, isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey, flowMonth, flowMonthValid]) useEffect(() => { if (!isLive) return void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([])) }, [isLive, backendUrl]) 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), status: projectTrafficBindingStatus(c.status,c.statusObservedAt,c.statusExpiresAt,trafficClock) })) : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces, trafficClock]) const visibleIfaces = useMemo( () => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces, [serverIfaces, hideDisabledIfaces], ) useEffect(() => { if (!confirmedLiveServer || effectiveMode !== "servers") { // eslint-disable-next-line react-hooks/set-state-in-effect setServerIfaces([]) setSelectedIface("__all__") return } let cancelled = false setServerIfaces([]) setSelectedIface("__all__") apiFetch<{ interfaces: LiveTrafficInterface[] }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}/interfaces`) .then((res) => { if (!cancelled) setServerIfaces(res.interfaces) }) .catch(() => { if (!cancelled) setServerIfaces([]) }) return () => { cancelled = true } }, [confirmedLiveServer, selectedId, effectiveMode, apiFetch]) useEffect(() => { if (!confirmedLiveServer || effectiveMode !== "servers") { return } let cancelled = false apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`) .then((res) => { if (cancelled) return setDetailResult({ key: detailKey, receivedAt: Date.now(), error: null, server: { ...toLiveServer(res.server), boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId), } }) }) .catch((error: unknown) => { if (!cancelled) setDetailResult({ key: detailKey, server: null, error: error instanceof Error ? error.message : "Не удалось загрузить детали трафика" }) }) return () => { cancelled = true } }, [confirmedLiveServer, effectiveMode, selectedId, range, selectedIface, apiFetch, liveBoundIfaces, detailKey]) const handleModeChange = (next: GroupMode) => { setGroupMode(next) if (next === "servers") { setSelectedId(activeServerTraffic[0]?.id ?? "srv1") if (range === "30d") setRange("1h") } else if (next === "users") { setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1") if (range === "30d") setRange("1h") } else if (next === "ifaces") { setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "") if (range === "30d") setRange("1h") } else if (next === "flows") { setFlowScope("servers") setFlowIface("__all__") setRange("5m") setSelectedId(flowExporters[0]?.id ?? "") } setSortField("rx") setSortDir("desc") setSearch("") } const toggleSort = (field: SortField) => { if (sortField === field) setSortDir(d => d === "desc" ? "asc" : "desc") else { setSortField(field); setSortDir("desc") } } const q = search.toLowerCase() const sortedServers = useMemo(() => { return [...activeServerTraffic] .filter(s => !q || s.name.toLowerCase().includes(q) || s.site.toLowerCase().includes(q)) .sort((a, b) => { let v = 0 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 }) }, [sortField, sortDir, q, activeServerTraffic]) const sortedUsers = useMemo(() => { return [...activeUserTraffic] .filter(u => !q || u.login.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q)) .sort((a, b) => { let v = 0 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 }) }, [sortField, sortDir, q, activeUserTraffic]) const sortedIfaces = useMemo(() => { return [...activeBoundIfaces] .filter(c => !q || c.interfaceName.toLowerCase().includes(q) || (c.peerName ?? "").toLowerCase().includes(q) || c.comment.toLowerCase().includes(q) || c.userLogin.toLowerCase().includes(q)) .sort((a, b) => { let v = 0 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 }) }, [sortField, sortDir, q, activeBoundIfaces]) const flowCards = flowScope === "users" ? flowClients : flowExporters const sortedFlowCards = useMemo(() => { return [...flowCards] .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 = 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 }) }, [flowCards, q, sortField, sortDir]) const monthlyServers = currentFlows?.monthlyServers ?? [] const visibleMonthlyServers = monthlyServers.filter(server => !q || server.name.toLowerCase().includes(q)).sort((a, b) => a.name.localeCompare(b.name)) const monthlyServer = monthlyServers.find(server => String(server.id) === selectedId) const selFlowCard = range === "30d" ? (monthlyServer ? { id: String(monthlyServer.id), name: monthlyServer.name, country: monthlyServer.country, site: monthlyServer.site } : null) : flowCards.find((c) => c.id === selectedId) ?? null 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 ? { ...currentDetail.server, ...projectTrafficObservations(currentDetail.server, trafficClock), boundIfaces: currentDetail.server.boundIfaces.map(toLiveBound) } : 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]) const kpiSource = effectiveMode === "users" ? activeUserTraffic : effectiveMode === "ifaces" ? activeBoundIfaces : activeServerTraffic 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) const collectorAlive = Boolean(flowStats?.listenerBound || flowStats?.packetsReceived) const flowError = liveError || flowLiveError || (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null) const flowLiveHint = flowLiveError ? (flowLiveSample ? "Последние данные · live недоступен" : "История · live недоступен") : flowLiveSample ? "live" : undefined const flowKpiItems = [ { id: "exporters", label: "Экспортёры", value: String(flowExporters.length), icon: , iconClassName: "text-info", }, { id: "bps", label: "Скорость", value: displayedFlow ? fmtRate(displayedFlow.bpsNow / 1_000_000) : "—", hint: flowLiveHint, icon: , iconClassName: "text-success", }, { id: "src", label: "Уник. src", value: displayedFlow ? String(displayedFlow.uniqueSrc) : "—", icon: , iconClassName: "text-muted-foreground", }, { id: "proto", label: "Топ протокол", value: displayedFlow?.topProto ?? "—", icon: , iconClassName: "text-warning", }, ] const counterKpiItems = [ { id: "rx", label: "RX", value: fmtRate(totalRx), icon: , iconClassName: "text-success", }, { id: "tx", label: "TX", value: fmtRate(totalTx), icon: , iconClassName: "text-info", }, { id: "peak-rx", label: "Пик RX", value: fmtRate(peakRx), icon: , iconClassName: "text-warning", }, { id: "peak-tx", label: "Пик TX", value: fmtRate(peakTx), icon: , iconClassName: "text-warning", }, ] return (
} /> {/* ── mode tab bar ── */}
{GROUP_MODES.map(({ mode, icon, label }) => ( ))}
{!(effectiveMode === "flows" && range === "30d") && (!isLive || !(effectiveMode === "flows" ? liveError : trafficError)) && } {isLive && effectiveMode !== "flows" && trafficReady && } {effectiveMode === "flows" ? (

{ingestLine ?? "IPFIX коллектор"}

{isLive && } {liveBusy &&

Загрузка потоков…

} {liveError && Потоки недоступны{liveError}} {flowError && !liveError && (
{flowError}
)}
setSearch(e.target.value)} aria-label="Поиск потоков" placeholder="Поиск…" className="w-full pl-8 pr-3 h-8 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors" />
Сортировка: {visibleSortFields.map(({ field, label }) => ( ))}
{range === "30d" &&

Серверы из каталога, включая неактивные. Удалённые серверы в список не входят.

}
{range === "30d" ? visibleMonthlyServers.map(server => ) : sortedFlowCards.map((card) => ( { setFlowIface("__all__"); setSelectedId(card.id) }} /> ))} {!liveBusy && !liveError && (range === "30d" ? visibleMonthlyServers.length : sortedFlowCards.length) === 0 ? (

{range === "30d" ? (q ? "Нет серверов по этому запросу" : "В каталоге нет серверов") : flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}

) : null}
{analyticsLoading &&

Загрузка аналитики…

} {currentAnalytics?.error && Аналитика недоступна{currentAnalytics.error}} {isLive && (displayedFlow || (monthlyServer && currentAnalytics?.monthly)) && } setRange(r as Range)} selectedIface={flowIface} onIface={setFlowIface} dedup={flowDedup} onDedup={setFlowDedup} excludeMesh={flowExcludeMesh} onExcludeMesh={setFlowExcludeMesh} excludeOverlay={flowExcludeOverlay} onExcludeOverlay={setFlowExcludeOverlay} liveHint={flowLiveHint} emptyHint={analyticsLoading ? "Загрузка аналитики…" : currentAnalytics?.error ? "Не удалось получить данные выбранного среза" : flowEmptyHint(flowStats, collectorAlive)} />
{ setFlowRetry(value => value + 1) }} />
) : (
{/* ── left panel ── */}
{/* search */}
setSearch(e.target.value)} aria-label="Поиск трафика" placeholder="Поиск…" className="w-full pl-8 pr-3 h-8 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors" />
{/* sort controls */}
Сортировка: {visibleSortFields.map(({ field, label }) => ( ))}
{isLive && trafficLoading &&

Загрузка списка трафика…

} {trafficError && Список трафика недоступен{trafficError}} {(!isLive || trafficReady) && (effectiveMode === "servers" ? sortedServers.length : effectiveMode === "users" ? sortedUsers.length : sortedIfaces.length) === 0 && (

{search.trim() ? "По вашему запросу ничего не найдено" : effectiveMode === "servers" ? "Нет серверов с данными трафика" : effectiveMode === "users" ? "Нет клиентов с данными трафика" : "Нет привязанных интерфейсов с данными трафика"}

)} {/* entity list */}
{effectiveMode === "servers" && sortedServers.map(s => ( setSelectedId(s.id)} /> ))} {effectiveMode === "users" && sortedUsers.map(u => ( setSelectedId(u.id)} /> ))} {effectiveMode === "ifaces" && sortedIfaces.map(c => ( setSelectedId(c.id)} /> ))}
{/* ── detail panel ── */} {isLive && effectiveMode === "servers" && (
Интерфейс: {visibleIfaces.map((iface, index) => { const active = selectedIface === iface.name return ( ) })} {detailBusy && Загрузка...}
)} {isLive && effectiveMode === "servers" && !detailServer && <>

{selServer?.name ?? "Трафик сервера"}

{detailBusy &&

Загрузка выбранного периода и интерфейса…

} {currentDetail?.error && Детали трафика недоступны{currentDetail.error}} } {isLive && effectiveMode === "servers" && detailServer && <> {liveStreamError && {liveStreamError === "Ожидание нового live-измерения" ? "Подключение live-потока" : "Live-поток: нет свежих данных"} {liveStreamError} } } {effectiveMode === "servers" && detailServer && ( )} {effectiveMode === "users" && selUser && } {effectiveMode === "ifaces" && selIface && }
)}
) }