feat(traffic): добавить карту и срезы IPFIX по странам и сессиям
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m6s
Docker images / frontend-image (push) Successful in 3m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 14s

Имена ifIndex пишутся по обоим ключам REST, дедуп 5-tuple по max байт, RIPEstat только из prefix-кэша, карта откуда-куда в Frame.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-07 02:13:21 +07:00
co-authored by Cursor
parent 37167f78e3
commit 2820683cba
26 changed files with 1365 additions and 89 deletions
+119 -49
View File
@@ -3,11 +3,13 @@
import { useMemo, useState } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard } from "@mmapp/contracts/traffic-flow"
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon } from "lucide-react"
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, UsersIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import { KpiStatGrid } 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,
@@ -15,7 +17,7 @@ import {
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
import { Sparkline } from "@/components/sparkline"
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"
@@ -106,7 +108,15 @@ export function FlowEntityCardView({
)
}
function FlowBreakdownGrid({ rows, empty }: { rows: FlowBreakdownRow[]; empty?: string }) {
function FlowBreakdownGrid({
rows,
empty,
country,
}: {
rows: FlowBreakdownRow[]
empty?: string
country?: boolean
}) {
const columns = useMemo<ColumnDef<FlowBreakdownRow>[]>(
() => [
{
@@ -115,7 +125,10 @@ function FlowBreakdownGrid({ rows, empty }: { rows: FlowBreakdownRow[]; empty?:
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="text-sm font-medium truncate">{row.original.label}</span>
<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"
@@ -154,7 +167,7 @@ function FlowBreakdownGrid({ rows, empty }: { rows: FlowBreakdownRow[]; empty?:
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[],
[country],
)
const table = useReactTable({
@@ -176,6 +189,8 @@ export function FlowAnalyticsDetail({
onRange,
selectedIface,
onIface,
dedup,
onDedup,
liveHint,
emptyHint,
}: {
@@ -185,12 +200,18 @@ export function FlowAnalyticsDetail({
onRange: (r: string) => void
selectedIface: string
onIface: (name: string) => void
dedup: boolean
onDedup: (value: boolean) => void
liveHint?: string
emptyHint?: string
}) {
const [slice, setSlice] = useState("applications")
const [mapCountry, setMapCountry] = useState<string | 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) =>
mapCountry ? row.dstCountry === mapCountry : true,
)
if (!card) {
return (
@@ -211,22 +232,34 @@ export function FlowAnalyticsDetail({
{card.site}
</span>
</div>
<div className="flex gap-1 shrink-0">
{RANGE_KEYS.map((r) => (
<button
key={r}
type="button"
onClick={() => onRange(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 className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2">
<Switch
id="flow-dedup"
checked={dedup}
onCheckedChange={onDedup}
/>
<Label htmlFor="flow-dedup" className="text-xs text-muted-foreground whitespace-nowrap">
Без дублей
</Label>
</div>
<div className="flex gap-1">
{RANGE_KEYS.map((r) => (
<button
key={r}
type="button"
onClick={() => onRange(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>
@@ -265,6 +298,7 @@ export function FlowAnalyticsDetail({
)
})}
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">по iface, без дедупа</p>
</div>
) : null}
@@ -295,11 +329,28 @@ export function FlowAnalyticsDetail({
},
{
id: "flows",
label: "Разговоры",
label: "Сессии",
value: String(analytics?.conversations ?? card.sessions),
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",
},
]}
/>
</div>
@@ -312,15 +363,41 @@ export function FlowAnalyticsDetail({
<Tabs value={slice} onValueChange={(v) => setSlice(String(v))} className="gap-3">
<TabsList variant="line" className="flex flex-wrap h-auto">
<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="conversations">Разговоры</TabsTrigger>
<TabsTrigger value="sessions">Сессии</TabsTrigger>
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
</TabsList>
<TabsContent value="applications">
<FlowBreakdownGrid rows={analytics?.applications ?? []} />
</TabsContent>
<TabsContent value="categories">
<FlowBreakdownGrid rows={analytics?.categories ?? []} />
</TabsContent>
<TabsContent value="services">
<FlowBreakdownGrid rows={analytics?.services ?? []} />
</TabsContent>
<TabsContent value="asns">
<FlowBreakdownGrid rows={analytics?.asns ?? []} />
</TabsContent>
<TabsContent value="countries">
<FlowBreakdownGrid rows={analytics?.countries ?? []} country />
</TabsContent>
<TabsContent value="map">
<FlowTrafficMap
edges={analytics?.mapEdges ?? []}
onSelectCountry={(iso) => {
setMapCountry(iso)
setSlice("sessions")
}}
/>
</TabsContent>
<TabsContent value="protocols">
<FlowBreakdownGrid rows={analytics?.protocols ?? []} />
</TabsContent>
@@ -330,40 +407,33 @@ export function FlowAnalyticsDetail({
<TabsContent value="destinations">
<FlowBreakdownGrid rows={analytics?.destinations ?? []} />
</TabsContent>
<TabsContent value="conversations">
<TabsContent value="sessions">
{mapCountry ? (
<div className="flex items-center gap-2 mb-2">
<GlobeIcon className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">фильтр страны {mapCountry}</span>
<button
type="button"
className="text-xs text-primary"
onClick={() => setMapCountry(null)}
>
сбросить
</button>
</div>
) : null}
<TrafficFlowsDataGrid
rows={analytics?.conversationsList ?? []}
rows={sessionRows}
emptyHint={emptyHint}
/>
</TabsContent>
<TabsContent value="interfaces">
<FlowBreakdownGrid rows={analytics?.interfaces ?? []} />
<FlowBreakdownGrid
rows={analytics?.interfaces ?? []}
empty="Нет данных по интерфейсам"
/>
</TabsContent>
</Tabs>
</div>
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-muted-foreground mb-1">Объём за период</p>
<p className="text-lg font-semibold tabular-nums">{formatBytes(bytes)}</p>
<Sparkline
data={analytics?.rxSeries ?? card.rxSeries}
width={180}
height={28}
color="var(--chart-rx)"
filled
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Уник. адреса</p>
<p className="text-lg font-semibold tabular-nums">
{analytics?.uniqueSrc ?? 0}
<span className="text-sm font-normal text-muted-foreground"> src · </span>
{analytics?.uniqueDst ?? 0}
<span className="text-sm font-normal text-muted-foreground"> dst</span>
</p>
</div>
</div>
</>
)
}