"use client" import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" import { useMemo, useState } from "react" import { type ColumnDef, useTable } from "@tanstack/react-table" import type { FlowMonthlyDto, 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 { TrafficMiniChart } from "@/components/reui-kit/traffic-mini-chart" 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 { Input } from "@/components/ui/input" 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 hasMeasurement(value: number | undefined): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0 } 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": "Месяц", } 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, monthly = false, onPick, }: { rows: FlowBreakdownRow[] empty?: string country?: boolean monthly?: 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 visibleColumns = useMemo(() => monthly ? columns.filter(column => column.id !== "rate") : columns, [columns, monthly]) const table = useTable({ features: dataGridFeatures, manualPagination: true, manualSorting: true, data: rows, columns: visibleColumns, 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 = useTable({ features: dataGridFeatures, manualPagination: true, manualSorting: true, data: rows, columns, getRowId: (row) => row.id, }) return ( ) } export function FlowAnalyticsDetail({ card, analytics, monthly, monthlyAvailable = true, month, onMonth, monthValid, range, onRange, selectedIface, onIface, dedup, onDedup, excludeMesh, onExcludeMesh, excludeOverlay, onExcludeOverlay, liveHint, emptyHint, }: { card: (Pick & Partial) | null analytics: FlowAnalyticsDto | null monthly?: FlowMonthlyDto monthlyAvailable?: boolean month: string onMonth: (value: string) => void monthValid: boolean 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") } return ( <>
{card?.status && }

{card?.name ?? "Выберите сервер или клиента"}

{card?.country && card.country !== "UN" ? : null} {card?.site}
{RANGE_KEYS.filter(r => r !== "30d" || monthlyAvailable).map((r) => ( ))}
{range === "30d" &&
onMonth(event.target.value)} aria-invalid={!monthValid} aria-describedby={!monthValid ? "flow-report-month-error" : undefined} /> {!monthValid && }
} {range === "30d" &&

Календарный месяц по UTC. Фильтры интерфейса, дублей, overlay и mesh к этому отчёту не применяются.

} {monthly && range === "30d" &&

Период: {monthly.month} (UTC) · Объём: {formatBytes(monthly.bytes)}

Страны

Сервисы

ASN

} {analytics?.ifaces && analytics.ifaces.length > 0 ? (
Интерфейс: {analytics.ifaces.map((iface) => { const active = selectedIface === iface.name return ( ) })}

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

) : null} {analytics && <>
, iconClassName: "text-success", }, { id: "bytes", label: "Payload", value: formatBytes(bytes), hint: "inner IPFIX", icon: , iconClassName: "text-info", }, { id: "overlay", label: "JH↔EN overlay", value: hasMeasurement(analytics?.bpsOverlay) ? fmtRate(analytics.bpsOverlay / 1_000_000) : "Нет данных", hint: hasMeasurement(analytics?.bytesOverlay) ? formatBytes(analytics.bytesOverlay) : undefined, icon: , iconClassName: "text-warning", }, { id: "wire", label: "Wire GRE", value: hasMeasurement(analytics?.bpsWire) ? fmtRate(analytics.bpsWire / 1_000_000) : "Нет данных", hint: "счётчик iface", icon: , iconClassName: "text-primary", }, ...(!excludeMesh ? [{ id: "mesh", label: "Mesh", value: hasMeasurement(analytics?.bytesMesh) ? formatBytes(analytics.bytesMesh) : "Нет данных", hint: "клиент↔клиент", icon: , iconClassName: "text-muted-foreground", }] : []), { id: "flows", label: "Сессии", value: String(analytics?.conversations ?? card?.sessions ?? 0), 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[])} />

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

{liveHint === "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)} />
} ) }