"use client" import { useMemo, useState } from "react" import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table" import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowPathRow, FlowTalkerDto } from "@mmapp/contracts/traffic-flow" import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, NetworkIcon, RouteIcon, ShieldIcon, UsersIcon } from "lucide-react" import { Badge } from "@/components/reui/badge" import { KpiStatGrid, type KpiStatItem } from "@/components/reui-kit/kpi-stat-grid" import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Switch } from "@/components/ui/switch" import { Label } from "@/components/ui/label" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, } from "@/components/data-grids/shared/data-grid-layout" import { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid" import { FlowTrafficMap } from "@/components/traffic/flow-traffic-map" import { StatusDot } from "@/components/status-dot" import { Flag } from "@/components/flag" import { fmtRate } from "@/lib/fmt-rate" import { cn } from "@/lib/utils" function formatBytes(n: number): string { if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ` if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ` if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ` return `${n} Б` } const RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h", "30d"] as const const RANGE_LABELS: Record = { "5m": "5м", "15m": "15м", "1h": "1ч", "4h": "4ч", "24h": "24ч", "30d": "месяц", } function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) { const W = 300 const H = height const maxVal = Math.max(...rx, ...tx, 1) * 1.1 const xAt = (i: number) => (rx.length <= 1 ? 0 : (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 ( {tx.some((v) => v > 0) ? ( ) : null} ) } export function FlowEntityCardView({ card, selected, onClick, }: { card: FlowEntityCard selected: boolean onClick: () => void }) { return ( ) } type SessionFilter = { kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface" | "client" | "en" value: string label: string } function talkerMatchesFilter(row: FlowTalkerDto, filter: SessionFilter): boolean { switch (filter.kind) { case "application": return row.application === filter.value case "category": return row.category === filter.value case "service": return row.service === filter.value case "asn": return String(row.dstAsn ?? "") === filter.value case "country": return row.dstCountry === filter.value case "protocol": return row.protoName === filter.value case "source": return row.src === filter.value case "destination": return row.dst === filter.value case "iface": return row.inIface === filter.value case "client": return (row.clientId || "unknown") === filter.value case "en": return (row.enId || "") === filter.value } } function FlowBreakdownGrid({ rows, empty, country, onPick, }: { rows: FlowBreakdownRow[] empty?: string country?: boolean onPick?: (row: FlowBreakdownRow) => void }) { const columns = useMemo[]>( () => [ { id: "label", accessorKey: "label", header: () => Имя, cell: ({ row }) => (
{country ? : null} {row.original.label}
), meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST }, }, { id: "share", accessorKey: "percent", header: () => Доля, cell: ({ row }) => ( {row.original.percent.toFixed(1)}% ), meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "rate", accessorFn: (r) => r.bps, header: () => Скорость, cell: ({ row }) => ( {fmtRate(row.original.bps / 1_000_000)} ), meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "bytes", accessorKey: "bytes", header: () => Байты, cell: ({ row }) => ( {formatBytes(row.original.bytes)} ), meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, }, ], [country], ) const table = useReactTable({ data: rows, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, }) return ( ) } function FlowPathsGrid({ rows, empty, onPick, }: { rows: FlowPathRow[] empty?: string onPick?: (row: FlowPathRow) => void }) { const columns = useMemo[]>( () => [ { id: "client", accessorKey: "clientName", header: () => Клиент, cell: ({ row }) => {row.original.clientName}, meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST }, }, { id: "ifaces", accessorKey: "ifaces", header: () => Ifaces, cell: ({ row }) => {row.original.ifaces}, meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "jh", accessorKey: "serverName", header: () => JH, cell: ({ row }) => {row.original.serverName}, meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "in", accessorKey: "inIface", header: () => In, cell: ({ row }) => {row.original.inIface}, meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "en", accessorKey: "enName", header: () => EN, cell: ({ row }) => {row.original.enName || "—"}, meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "dst", accessorKey: "dst", header: () => Dest, cell: ({ row }) => ( {row.original.dst} {[row.original.category, row.original.service].filter(Boolean).join(" · ")} ), meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "rate", accessorFn: (r) => r.bps, header: () => Скорость, cell: ({ row }) => {fmtRate(row.original.bps / 1_000_000)}, meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { id: "bytes", accessorKey: "bytes", header: () => Байты, cell: ({ row }) => {formatBytes(row.original.bytes)}, meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, }, ], [], ) const table = useReactTable({ data: rows, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, }) return ( ) } export function FlowAnalyticsDetail({ card, analytics, range, onRange, selectedIface, onIface, dedup, onDedup, excludeMesh, onExcludeMesh, excludeOverlay, onExcludeOverlay, liveHint, emptyHint, }: { card: FlowEntityCard | null analytics: FlowAnalyticsDto | null range: string onRange: (r: string) => void selectedIface: string onIface: (name: string) => void dedup: boolean onDedup: (value: boolean) => void excludeMesh: boolean onExcludeMesh: (value: boolean) => void excludeOverlay: boolean onExcludeOverlay: (value: boolean) => void liveHint?: string emptyHint?: string }) { const [slice, setSlice] = useState("applications") const [sessionFilter, setSessionFilter] = useState(null) const rxNow = analytics ? analytics.bpsNow / 1_000_000 : (card?.rxNow ?? 0) const bytes = analytics?.bytes ?? card?.bytes ?? 0 const sessionRows = (analytics?.conversationsList ?? []).filter((row) => sessionFilter ? talkerMatchesFilter(row, sessionFilter) : true, ) function pickBreakdown(kind: SessionFilter["kind"], row: FlowBreakdownRow) { setSessionFilter({ kind, value: row.id, label: row.label }) setSlice("sessions") } if (!card) { return (

Выберите сервер или клиента слева

) } return ( <>

{card.name}

{card.country !== "UN" ? : null} {card.site}
{RANGE_KEYS.map((r) => ( ))}
{analytics?.ifaces && analytics.ifaces.length > 0 ? (
Интерфейс: {analytics.ifaces.map((iface) => { const active = selectedIface === iface.name return ( ) })}

по iface, без дедупа

) : null}
, iconClassName: "text-success", }, { id: "bytes", label: "Payload", value: formatBytes(bytes), hint: "inner IPFIX", icon: , iconClassName: "text-info", }, { id: "overlay", label: "JH↔EN overlay", value: fmtRate((analytics?.bpsOverlay ?? 0) / 1_000_000), hint: analytics?.bytesOverlay ? formatBytes(analytics.bytesOverlay) : undefined, icon: , iconClassName: "text-warning", }, { id: "wire", label: "Wire GRE", value: fmtRate((analytics?.bpsWire ?? 0) / 1_000_000), hint: "счётчик iface", icon: , iconClassName: "text-primary", }, ...(!excludeMesh ? [{ id: "mesh", label: "Mesh", value: formatBytes(analytics?.bytesMesh ?? 0), hint: "клиент↔клиент", icon: , iconClassName: "text-muted-foreground", }] : []), { id: "flows", label: "Сессии", value: String(analytics?.conversations ?? card.sessions), hint: analytics?.conversationsRaw != null && analytics.conversationsRaw !== analytics.conversations ? `до дедупа ${analytics.conversationsRaw}` : undefined, icon: , iconClassName: "text-warning", }, { id: "uniq", label: "Уник. src / dst", value: `${analytics?.uniqueSrc ?? 0} / ${analytics?.uniqueDst ?? 0}`, icon: , iconClassName: "text-muted-foreground", }, { id: "category", label: "Топ категория", value: analytics?.topCategory ?? "—", icon: , iconClassName: "text-primary", }, ] satisfies KpiStatItem[])} />

Аналитика потребления

{analytics?.live ? live : null}
setSlice(String(v))} className="gap-3"> Приложения Категории Сервисы ASN Страны Карта Протоколы Источники Назначения Пути Сессии Интерфейсы pickBreakdown("application", row)} /> pickBreakdown("category", row)} /> pickBreakdown("service", row)} /> pickBreakdown("asn", row)} /> pickBreakdown("country", row)} /> { setSessionFilter({ kind: "country", value: iso, label: iso }) setSlice("sessions") }} /> pickBreakdown("protocol", row)} /> pickBreakdown("source", row)} /> pickBreakdown("destination", row)} /> { setSessionFilter({ kind: "client", value: row.clientId, label: `${row.clientName} → ${row.enName || row.dst}`, }) setSlice("sessions") }} /> {sessionFilter ? (
Фильтр: {sessionFilter.label}
) : null}
pickBreakdown("iface", row)} />
) }