feat(traffic): разделить плоскости IPFIX и показать путь JH→EN
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Failing after 1m31s
Docker images / frontend-image (push) Successful in 2m36s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped

Считать payload отдельно от overlay GRE/ESP и mesh; вкладка Пути и KPI Wire из счётчиков iface.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-07 11:39:14 +07:00
co-authored by Cursor
parent cb799da13a
commit 90c8c393e5
32 changed files with 1656 additions and 65 deletions
+174 -7
View File
@@ -2,10 +2,10 @@
import { useMemo, useState } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, UsersIcon } from "lucide-react"
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 } from "@/components/reui-kit/kpi-stat-grid"
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"
@@ -110,7 +110,7 @@ export function FlowEntityCardView({
}
type SessionFilter = {
kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface"
kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface" | "client" | "en"
value: string
label: string
}
@@ -126,6 +126,8 @@ function talkerMatchesFilter(row: FlowTalkerDto, filter: SessionFilter): boolean
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
}
}
@@ -210,6 +212,101 @@ function FlowBreakdownGrid({
)
}
function FlowPathsGrid({
rows,
empty,
onPick,
}: {
rows: FlowPathRow[]
empty?: string
onPick?: (row: FlowPathRow) => void
}) {
const columns = useMemo<ColumnDef<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 = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={rows.length}
emptyMessage={empty ?? "Нет путей"}
onRowClick={onPick}
/>
)
}
export function FlowAnalyticsDetail({
card,
analytics,
@@ -219,6 +316,10 @@ export function FlowAnalyticsDetail({
onIface,
dedup,
onDedup,
excludeMesh,
onExcludeMesh,
excludeOverlay,
onExcludeOverlay,
liveHint,
emptyHint,
}: {
@@ -230,6 +331,10 @@ export function FlowAnalyticsDetail({
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
}) {
@@ -276,6 +381,26 @@ export function FlowAnalyticsDetail({
Без дублей
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
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
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 gap-1">
{RANGE_KEYS.map((r) => (
<button
@@ -344,7 +469,7 @@ export function FlowAnalyticsDetail({
<div className="mt-4 pt-4 border-t">
<KpiStatGrid
aria-label="Скорость потоков"
items={[
items={([
{
id: "bps-now",
label: "Скорость сейчас",
@@ -355,11 +480,38 @@ export function FlowAnalyticsDetail({
},
{
id: "bytes",
label: "Байт за период",
label: "Payload",
value: formatBytes(bytes),
hint: "inner IPFIX",
icon: <ArrowUpIcon className="size-4" />,
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: <ShieldIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "wire",
label: "Wire GRE",
value: fmtRate((analytics?.bpsWire ?? 0) / 1_000_000),
hint: "счётчик iface",
icon: <RouteIcon className="size-4" />,
iconClassName: "text-primary",
},
...(!excludeMesh
? [{
id: "mesh",
label: "Mesh",
value: formatBytes(analytics?.bytesMesh ?? 0),
hint: "клиент↔клиент",
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
}]
: []),
{
id: "flows",
label: "Сессии",
@@ -384,7 +536,7 @@ export function FlowAnalyticsDetail({
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
] satisfies KpiStatItem[])}
/>
</div>
@@ -404,6 +556,7 @@ export function FlowAnalyticsDetail({
<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>
@@ -440,6 +593,20 @@ export function FlowAnalyticsDetail({
<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">
{sessionFilter ? (
<div className="flex items-center gap-2 mb-2">