"use client" import { useState, useMemo, useEffect, useCallback, type ReactNode } from "react" import { PageHeader } from "@/components/page-header" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" 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, DownloadIcon, TrendingUpIcon, TrendingDownIcon, ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, GitBranchIcon, PlusIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { fmtGB, 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." } function monthlyToAnalytics(m: FlowMonthlyDto): FlowAnalyticsDto { const emptySeries = Array(60).fill(0) as number[] return { bpsNow: 0, bytes: m.bytes, packets: 0, conversations: 0, conversationsRaw: 0, uniqueSrc: 0, uniqueDst: 0, topProto: "—", topCategory: "—", rxSeries: emptySeries, txSeries: emptySeries, applications: [], protocols: [], sources: [], destinations: [], interfaces: [], asns: m.asns, countries: m.countries, categories: [], services: m.services, mapEdges: [], conversationsList: [], paths: [], ifaces: [], live: false, degraded: false, bytesPayload: m.bytes, bytesOverlay: 0, bytesMesh: 0, bytesWire: 0, } } // ─── data model ─────────────────────────────────────────────────────────────── interface BoundIfaceTraffic { 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 txNow: number rxPeak: number txPeak: number rxTotal: number txTotal: number rxSeries: number[] txSeries: number[] status: "online" | "offline" } interface ServerTraffic { id: string name: string site: string country: string status: "online" | "offline" | "degraded" rxNow: number txNow: number rxPeak: number txPeak: number rxTotal: number txTotal: number sessions: number rxSeries: number[] txSeries: number[] boundIfaces: BoundIfaceTraffic[] } interface UserTraffic { 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[] } interface LiveTrafficServer { id: string name: string site: string country: string status: "online" | "offline" | "degraded" rxNow: number txNow: number rxPeak: number txPeak: number rxTotal: number txTotal: number sessions: number rxSeries: number[] txSeries: number[] } interface LiveTrafficInterface { name: string running: boolean disabled: boolean rxNow: number txNow: number } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } 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) 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[]) 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}

) } function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) { const W = 300, H = height const maxVal = Math.max(...rx, ...tx, 1) * 1.1 const xAt = (i: number) => (i / (rx.length - 1)) * W const yAt = (v: number) => H - (v / maxVal) * H const area = (arr: number[]) => { const pts = arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ") return `M 0,${H} L ${pts} L ${W},${H} Z` } const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ") return ( ) } // ─── 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)}

{fmtGB(c.rxTotal)}

{fmtGB(c.txTotal)}

{c.status === "online" ? "online" : "offline"}
) } 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; txTotal: number; rxSeries: number[]; txSeries: number[] }) { return (

Получено за период

{rxTotal.toFixed(1)} ГБ

Отправлено за период

{txTotal.toFixed(1)} ГБ

) } // ─── 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}
{sel.status === "offline" ? : }
, 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}
{sel.status === "offline" ? : }
} 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"] }, ] export default function TrafficPage() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" 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 [liveServers, setLiveServers] = useState([]) const [liveBusy, setLiveBusy] = useState(false) const [liveError, setLiveError] = useState(null) const [serverIfaces, setServerIfaces] = useState([]) const [selectedIface, setSelectedIface] = useState("__all__") const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true) const [detailBusy, setDetailBusy] = useState(false) const [liveDetailServer, setLiveDetailServer] = useState(null) const [liveUsers, setLiveUsers] = useState([]) const [liveBoundIfaces, setLiveBoundIfaces] = useState([]) const [flowStats, setFlowStats] = useState(null) const [flowScope, setFlowScope] = useState("servers") const [flowExporters, setFlowExporters] = useState([]) const [flowClients, setFlowClients] = useState([]) const [flowAnalytics, setFlowAnalytics] = useState(null) const [flowIface, setFlowIface] = useState("__all__") 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 { sample: liveSample, error: liveStreamError } = useTrafficLive({ enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId), backendUrl, serverId: selectedId, iface: selectedIface, }) const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) && range === "5m" const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({ enabled: flowLiveEnabled, backendUrl, range, serverId: flowScope === "servers" ? selectedId : undefined, userId: flowScope === "users" ? selectedId : undefined, iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay, }) const toLiveServer = (s: LiveTrafficServer): ServerTraffic => { return { id: String(s.id), name: s.name, 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.length ? s.rxSeries : Array(60).fill(0), txSeries: s.txSeries.length ? s.txSeries : Array(60).fill(0), boundIfaces: [], } } const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({ ...c, interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType, rxSeries: c.rxSeries.length ? c.rxSeries : Array(60).fill(0), txSeries: c.txSeries.length ? c.txSeries : Array(60).fill(0), }) 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, rxSeries: u.rxSeries.length ? u.rxSeries : Array(60).fill(0), txSeries: u.txSeries.length ? u.txSeries : Array(60).fill(0), } } const loadLiveTraffic = useCallback(async (targetRange: Range) => { if (!isLive) return setLiveBusy(true) setLiveError(null) 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}`), ]) 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) setLiveUsers(mappedUsers) setLiveBoundIfaces(liveIfaces) 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) { setLiveError(e instanceof Error ? e.message : "Не удалось загрузить live трафик") } finally { setLiveBusy(false) } }, [apiFetch, isLive, groupMode]) useEffect(() => { if (!isLive) { // eslint-disable-next-line react-hooks/set-state-in-effect setLiveServers([]) setLiveUsers([]) setLiveBoundIfaces([]) setLiveError(null) return } void loadLiveTraffic(range) }, [isLive, range, loadLiveTraffic]) 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]) 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]) useEffect(() => { setFlowIface("__all__") }, [selectedId, flowScope]) useEffect(() => { if (!isLive) return void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([])) }, [isLive, backendUrl]) const activeServerTraffic = isLive ? liveServers : serverTraffic const activeUserTraffic = isLive ? liveUsers : userTraffic const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces const visibleIfaces = useMemo( () => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces, [serverIfaces, hideDisabledIfaces], ) useEffect(() => { if (!isLive || effectiveMode !== "servers" || !selectedId) { // eslint-disable-next-line react-hooks/set-state-in-effect setServerIfaces([]) setSelectedIface("__all__") setLiveDetailServer(null) return } setSelectedIface("__all__") apiFetch<{ interfaces: LiveTrafficInterface[] }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}/interfaces`) .then((res) => setServerIfaces(res.interfaces)) .catch(() => setServerIfaces([])) }, [isLive, selectedId, effectiveMode, apiFetch]) useEffect(() => { if (!isLive || effectiveMode !== "servers" || !selectedId) { // eslint-disable-next-line react-hooks/set-state-in-effect setLiveDetailServer(null) return } setDetailBusy(true) apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`) .then((res) => setLiveDetailServer({ ...toLiveServer(res.server), boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId), })) .catch(() => setLiveDetailServer(null)) .finally(() => setDetailBusy(false)) }, [isLive, effectiveMode, selectedId, range, selectedIface, apiFetch, liveBoundIfaces]) 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 = a.rxNow - b.rxNow else if (sortField === "tx") v = a.txNow - b.txNow 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 = a.rxNow - b.rxNow else if (sortField === "tx") v = a.txNow - b.txNow 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 = a.rxNow - b.rxNow else if (sortField === "tx") v = a.txNow - b.txNow 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 = a.rxNow - b.rxNow else if (sortField === "tx") v = a.txNow - b.txNow 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 selFlowCard = sortedFlowCards.find((c) => c.id === selectedId) ?? sortedFlowCards[0] ?? null const displayedFlow = flowLiveSample ?? flowAnalytics const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic]) const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : 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.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 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 flowKpiItems = [ { id: "exporters", label: "Экспортёры", value: String(flowExporters.length), icon: , iconClassName: "text-info", }, { id: "bps", label: "Скорость", value: displayedFlow ? fmtRate(displayedFlow.bpsNow / 1_000_000) : (flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—"), hint: displayedFlow?.live ? "live" : undefined, icon: , iconClassName: "text-success", }, { id: "src", label: "Уник. src", value: String(displayedFlow?.uniqueSrc ?? flowStats?.uniqueSrc ?? 0), icon: , iconClassName: "text-muted-foreground", }, { id: "proto", label: "Топ протокол", value: displayedFlow?.topProto ?? flowStats?.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" ? (

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

{flowError && (
{flowError}
)}
setSearch(e.target.value)} 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 }) => ( ))}
{sortedFlowCards.map((card) => ( setSelectedId(card.id)} /> ))} {sortedFlowCards.length === 0 ? (

{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}

) : null}
setRange(r as Range)} selectedIface={flowIface} onIface={setFlowIface} dedup={flowDedup} onDedup={setFlowDedup} excludeMesh={flowExcludeMesh} onExcludeMesh={setFlowExcludeMesh} excludeOverlay={flowExcludeOverlay} onExcludeOverlay={setFlowExcludeOverlay} liveHint={displayedFlow?.live ? "live" : undefined} emptyHint={flowEmptyHint(flowStats, collectorAlive)} />
{ void loadFlows() }} />
) : (
{/* ── left panel ── */}
{/* search */}
setSearch(e.target.value)} 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 }) => ( ))}
{liveError && (
{liveError}
)} {/* 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 && Загрузка...}
)} {effectiveMode === "servers" && detailServer && ( )} {effectiveMode === "users" && selUser && } {effectiveMode === "ifaces" && selIface && }
)}
) }