Files
MikrotikManager/components/traffic/flow-analytics-panel.tsx
T

660 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, string> = {
"5m": "5м",
"15m": "15м",
"1h": "1ч",
"4h": "4ч",
"24h": "24ч",
"30d": "Месяц",
}
export function FlowEntityCardView({
card,
selected,
onClick,
}: {
card: FlowEntityCard
selected: boolean
onClick: () => void
}) {
return (
<button
type="button"
aria-pressed={selected}
onClick={onClick}
className={cn(
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
selected ? "border-primary bg-primary/5" : "border-border bg-card",
card.status === "offline" && "opacity-60",
)}
>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5 min-w-0">
<StatusDot status={card.status} />
<span className="text-xs font-medium truncate">{card.name}</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground shrink-0 flex items-center gap-1">
{card.country !== "UN" ? <Flag code={card.country} /> : null}
{card.site}
</span>
</div>
<TrafficMiniChart rx={card.rxSeries} tx={card.txSeries} />
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-success" />
<span className="font-mono font-medium text-success">{fmtRate(card.rxNow)}</span>
</div>
<div className="flex items-center gap-1 text-[11px]">
<ArrowUpIcon className="size-3 text-info" />
<span className="font-mono font-medium text-info">{fmtRate(card.txNow)}</span>
</div>
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
<GitBranchIcon className="size-3" />{card.sessions}
</div>
</div>
</button>
)
}
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<ColumnDef<DataGridFeatures, FlowBreakdownRow>[]>(
() => [
{
id: "label",
accessorKey: "label",
header: () => <span className="text-xs font-medium text-muted-foreground">Имя</span>,
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5 text-sm font-medium truncate">
{country ? <Flag code={row.original.id} /> : null}
{row.original.label}
</span>
<div className="h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary"
style={{ width: `${Math.min(100, row.original.percent)}%` }}
/>
</div>
</div>
),
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "share",
accessorKey: "percent",
header: () => <span className="text-xs font-medium text-muted-foreground">Доля</span>,
cell: ({ row }) => (
<span className="text-xs tabular-nums">{row.original.percent.toFixed(1)}%</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "rate",
accessorFn: (r) => r.bps,
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
cell: ({ row }) => (
<span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "bytes",
accessorKey: "bytes",
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
cell: ({ row }) => (
<span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>
),
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 (
<DataGridShell
table={table}
recordCount={rows.length}
emptyMessage={empty ?? "Нет данных за период"}
onRowClick={onPick}
/>
)
}
function FlowPathsGrid({
rows,
empty,
onPick,
}: {
rows: FlowPathRow[]
empty?: string
onPick?: (row: FlowPathRow) => void
}) {
const columns = useMemo<ColumnDef<DataGridFeatures, FlowPathRow>[]>(
() => [
{
id: "client",
accessorKey: "clientName",
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
cell: ({ row }) => <span className="text-sm font-medium">{row.original.clientName}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "ifaces",
accessorKey: "ifaces",
header: () => <span className="text-xs font-medium text-muted-foreground">Ifaces</span>,
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.ifaces}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "jh",
accessorKey: "serverName",
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
cell: ({ row }) => <span className="text-xs">{row.original.serverName}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "in",
accessorKey: "inIface",
header: () => <span className="text-xs font-medium text-muted-foreground">In</span>,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.inIface}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "en",
accessorKey: "enName",
header: () => <span className="text-xs font-medium text-muted-foreground">EN</span>,
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "dst",
accessorKey: "dst",
header: () => <span className="text-xs font-medium text-muted-foreground">Dest</span>,
cell: ({ row }) => (
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
<span className="font-mono">{row.original.dst}</span>
<span className="text-[10px] text-muted-foreground truncate">
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
</span>
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "rate",
accessorFn: (r) => r.bps,
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "bytes",
accessorKey: "bytes",
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
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 (
<DataGridShell
table={table}
recordCount={rows.length}
emptyMessage={empty ?? "Нет путей"}
onRowClick={onPick}
/>
)
}
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<FlowEntityCard, "id" | "name" | "country" | "site"> & Partial<FlowEntityCard>) | 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<SessionFilter | null>(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 (
<>
<div className="flex flex-wrap items-start justify-between mb-3 gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">
{card?.status && <StatusDot status={card.status} />}
<h2 className="text-base font-semibold truncate">{card?.name ?? "Выберите сервер или клиента"}</h2>
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1">
{card?.country && card.country !== "UN" ? <Flag code={card.country} /> : null}
{card?.site}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 min-w-0">
<div className="flex items-center gap-2">
<Switch
disabled={range === "30d"}
id="flow-dedup"
checked={dedup}
onCheckedChange={onDedup}
/>
<Label htmlFor="flow-dedup" className="text-xs text-muted-foreground whitespace-nowrap">
Без дублей
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
disabled={range === "30d"}
id="flow-exclude-overlay"
checked={excludeOverlay}
onCheckedChange={onExcludeOverlay}
/>
<Label htmlFor="flow-exclude-overlay" className="text-xs text-muted-foreground whitespace-nowrap">
Без overlay
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
disabled={range === "30d"}
id="flow-exclude-mesh"
checked={excludeMesh}
onCheckedChange={onExcludeMesh}
/>
<Label htmlFor="flow-exclude-mesh" className="text-xs text-muted-foreground whitespace-nowrap">
Без mesh
</Label>
</div>
<div className="flex flex-wrap gap-1">
{RANGE_KEYS.filter(r => r !== "30d" || monthlyAvailable).map((r) => (
<button
key={r}
type="button"
onClick={() => onRange(r)}
aria-pressed={range === r}
className={cn(
"h-7 px-2 text-xs rounded border transition-colors",
range === r
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{RANGE_LABELS[r]}
</button>
))}
</div>
</div>
</div>
{range === "30d" && <div className="mb-3 flex flex-col gap-2">
<Label htmlFor="flow-report-month">Месяц отчёта (UTC)</Label>
<Input id="flow-report-month" type="month" value={month} max={new Date().toISOString().slice(0, 7)} onChange={event => onMonth(event.target.value)} aria-invalid={!monthValid} aria-describedby={!monthValid ? "flow-report-month-error" : undefined} />
{!monthValid && <p id="flow-report-month-error" role="alert" className="text-sm text-destructive">Выберите существующий месяц не позднее текущего.</p>}
</div>}
{range === "30d" && <p className="text-sm text-muted-foreground mb-3">Календарный месяц по UTC. Фильтры интерфейса, дублей, overlay и mesh к этому отчёту не применяются.</p>}
{monthly && range === "30d" && <section aria-label="Месячный отчёт" className="flex flex-col gap-4">
<p className="text-sm">Период: {monthly.month} (UTC) · Объём: {formatBytes(monthly.bytes)}</p>
<div><h3 className="text-sm font-medium mb-2">Страны</h3><FlowBreakdownGrid rows={monthly.countries} country monthly /></div>
<div><h3 className="text-sm font-medium mb-2">Сервисы</h3><FlowBreakdownGrid rows={monthly.services} monthly /></div>
<div><h3 className="text-sm font-medium mb-2">ASN</h3><FlowBreakdownGrid rows={monthly.asns} monthly /></div>
</section>}
{analytics?.ifaces && analytics.ifaces.length > 0 ? (
<div className="mb-3 pb-3 border-b">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-muted-foreground mr-1">Интерфейс:</span>
<button
type="button"
onClick={() => onIface("__all__")}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
selectedIface === "__all__"
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}
>
Все
</button>
{analytics.ifaces.map((iface) => {
const active = selectedIface === iface.name
return (
<button
key={`${iface.name}:${iface.index}`}
type="button"
onClick={() => onIface(iface.name)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}
>
{iface.name}
</button>
)
})}
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">по iface, без дедупа</p>
</div>
) : null}
{analytics && <>
<TrafficRxTxChart
rx={analytics?.rxSeries ?? card?.rxSeries ?? []}
tx={analytics?.txSeries ?? card?.txSeries ?? []}
range={range}
/>
<div className="mt-4 pt-4 border-t">
<KpiStatGrid
aria-label="Скорость потоков"
items={([
{
id: "bps-now",
label: "Скорость сейчас",
value: fmtRate(rxNow),
hint: liveHint,
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "bytes",
label: "Payload",
value: formatBytes(bytes),
hint: "inner IPFIX",
icon: <ArrowUpIcon className="size-4" />,
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: <ShieldIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "wire",
label: "Wire GRE",
value: hasMeasurement(analytics?.bpsWire) ? fmtRate(analytics.bpsWire / 1_000_000) : "Нет данных",
hint: "счётчик iface",
icon: <RouteIcon className="size-4" />,
iconClassName: "text-primary",
},
...(!excludeMesh
? [{
id: "mesh",
label: "Mesh",
value: hasMeasurement(analytics?.bytesMesh) ? formatBytes(analytics.bytesMesh) : "Нет данных",
hint: "клиент↔клиент",
icon: <NetworkIcon className="size-4" />,
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: <GitBranchIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "uniq",
label: "Уник. src / dst",
value: `${analytics?.uniqueSrc ?? 0} / ${analytics?.uniqueDst ?? 0}`,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "category",
label: "Топ категория",
value: analytics?.topCategory ?? "—",
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
] satisfies KpiStatItem[])}
/>
</div>
<div className="mt-4 pt-4 border-t flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">Аналитика потребления</p>
{liveHint === "live" ? <Badge variant="success-light" size="sm">live</Badge> : null}
</div>
<Tabs value={slice} onValueChange={(v) => setSlice(String(v))} className="gap-3">
<TabsList variant="line" className="flex w-full shrink-0 flex-wrap gap-y-2 group-data-horizontal/tabs:h-auto [&_[data-slot=tabs-trigger]]:h-9 [&_[data-slot=tabs-trigger]]:flex-none">
<TabsTrigger value="applications">Приложения</TabsTrigger>
<TabsTrigger value="categories">Категории</TabsTrigger>
<TabsTrigger value="services">Сервисы</TabsTrigger>
<TabsTrigger value="asns">ASN</TabsTrigger>
<TabsTrigger value="countries">Страны</TabsTrigger>
<TabsTrigger value="map">Карта</TabsTrigger>
<TabsTrigger value="protocols">Протоколы</TabsTrigger>
<TabsTrigger value="sources">Источники</TabsTrigger>
<TabsTrigger value="destinations">Назначения</TabsTrigger>
<TabsTrigger value="paths">Пути</TabsTrigger>
<TabsTrigger value="sessions">Сессии</TabsTrigger>
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
</TabsList>
<TabsContent value="applications">
<FlowBreakdownGrid rows={analytics?.applications ?? []} onPick={(row) => pickBreakdown("application", row)} />
</TabsContent>
<TabsContent value="categories">
<FlowBreakdownGrid rows={analytics?.categories ?? []} onPick={(row) => pickBreakdown("category", row)} />
</TabsContent>
<TabsContent value="services">
<FlowBreakdownGrid rows={analytics?.services ?? []} onPick={(row) => pickBreakdown("service", row)} />
</TabsContent>
<TabsContent value="asns">
<FlowBreakdownGrid rows={analytics?.asns ?? []} onPick={(row) => pickBreakdown("asn", row)} />
</TabsContent>
<TabsContent value="countries">
<FlowBreakdownGrid rows={analytics?.countries ?? []} country onPick={(row) => pickBreakdown("country", row)} />
</TabsContent>
<TabsContent value="map">
<FlowTrafficMap
edges={analytics?.mapEdges ?? []}
onSelectCountry={(iso) => {
setSessionFilter({ kind: "country", value: iso, label: iso })
setSlice("sessions")
}}
/>
</TabsContent>
<TabsContent value="protocols">
<FlowBreakdownGrid rows={analytics?.protocols ?? []} onPick={(row) => pickBreakdown("protocol", row)} />
</TabsContent>
<TabsContent value="sources">
<FlowBreakdownGrid rows={analytics?.sources ?? []} onPick={(row) => pickBreakdown("source", row)} />
</TabsContent>
<TabsContent value="destinations">
<FlowBreakdownGrid rows={analytics?.destinations ?? []} onPick={(row) => pickBreakdown("destination", row)} />
</TabsContent>
<TabsContent value="paths">
<FlowPathsGrid
rows={analytics?.paths ?? []}
empty="Нет путей за период"
onPick={(row) => {
setSessionFilter({
kind: "client",
value: row.clientId,
label: `${row.clientName} → ${row.enName || row.dst}`,
})
setSlice("sessions")
}}
/>
</TabsContent>
<TabsContent value="sessions">
<p role="status" aria-label="Выборка сессий" className="text-xs text-muted-foreground">
Показано {sessionRows.length} из {analytics?.conversationsList.length ?? 0} в выборке.
{" "}Сессии с наибольшим объёмом трафика (до 50). Фильтр применяется к этой выборке; другие сессии могут не отображаться.
</p>
{sessionFilter ? (
<div className="flex items-center gap-2 mb-2">
<GlobeIcon className="size-3.5 text-muted-foreground" />
<Badge variant="secondary" size="sm">Фильтр: {sessionFilter.label}</Badge>
<button
type="button"
className="text-xs text-primary"
onClick={() => setSessionFilter(null)}
>
сбросить
</button>
</div>
) : null}
<TrafficFlowsDataGrid
rows={sessionRows}
emptyHint={sessionFilter ? "В полученной выборке нет сессий по выбранному фильтру" : emptyHint ?? "В полученной выборке нет сессий"}
/>
</TabsContent>
<TabsContent value="interfaces">
<FlowBreakdownGrid
rows={analytics?.interfaces ?? []}
empty="Нет данных по интерфейсам"
onPick={(row) => pickBreakdown("iface", row)}
/>
</TabsContent>
</Tabs>
</div>
</>}
</>
)
}