feat(traffic): показать аналитику IPFIX по серверам и интерфейсам
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 11s
Резолвить ifIndex в имена RouterOS, дать вкладке Потоки ту же оболочку сервер/клиент/iface, что у обычного трафика, и обновлять срезы live без перезагрузки. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -59,6 +59,15 @@ function TrafficFlowsDataGrid({
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "app",
|
||||
accessorFn: (r) => r.application ?? r.protoName,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">App</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.application ?? row.original.protoName}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
accessorKey: "protoName",
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client"
|
||||
|
||||
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 { 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 { 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 { Sparkline } from "@/components/sparkline"
|
||||
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"] as const
|
||||
const RANGE_LABELS: Record<string, string> = {
|
||||
"5m": "5м",
|
||||
"15m": "15м",
|
||||
"1h": "1ч",
|
||||
"4h": "4ч",
|
||||
"24h": "24ч",
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-11" preserveAspectRatio="none">
|
||||
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity="0.15" />
|
||||
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.5" />
|
||||
{tx.some((v) => v > 0) ? (
|
||||
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.5" />
|
||||
) : null}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowEntityCardView({
|
||||
card,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
card: FlowEntityCard
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<MiniAreaChart 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>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowBreakdownGrid({ rows, empty }: { rows: FlowBreakdownRow[]; empty?: string }) {
|
||||
const columns = useMemo<ColumnDef<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="text-sm font-medium truncate">{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 },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell table={table} recordCount={rows.length} emptyMessage={empty ?? "Нет данных за период"} />
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowAnalyticsDetail({
|
||||
card,
|
||||
analytics,
|
||||
range,
|
||||
onRange,
|
||||
selectedIface,
|
||||
onIface,
|
||||
liveHint,
|
||||
emptyHint,
|
||||
}: {
|
||||
card: FlowEntityCard | null
|
||||
analytics: FlowAnalyticsDto | null
|
||||
range: string
|
||||
onRange: (r: string) => void
|
||||
selectedIface: string
|
||||
onIface: (name: string) => void
|
||||
liveHint?: string
|
||||
emptyHint?: string
|
||||
}) {
|
||||
const [slice, setSlice] = useState("applications")
|
||||
const rxNow = analytics ? analytics.bpsNow / 1_000_000 : (card?.rxNow ?? 0)
|
||||
const bytes = analytics?.bytes ?? card?.bytes ?? 0
|
||||
|
||||
if (!card) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Выберите сервер или клиента слева
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between mb-3 gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<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 !== "UN" ? <Flag code={card.country} /> : null}
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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: "Байт за период",
|
||||
value: formatBytes(bytes),
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "flows",
|
||||
label: "Разговоры",
|
||||
value: String(analytics?.conversations ?? card.sessions),
|
||||
icon: <GitBranchIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
{analytics?.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 flex-wrap h-auto">
|
||||
<TabsTrigger value="applications">Приложения</TabsTrigger>
|
||||
<TabsTrigger value="protocols">Протоколы</TabsTrigger>
|
||||
<TabsTrigger value="sources">Источники</TabsTrigger>
|
||||
<TabsTrigger value="destinations">Назначения</TabsTrigger>
|
||||
<TabsTrigger value="conversations">Разговоры</TabsTrigger>
|
||||
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="applications">
|
||||
<FlowBreakdownGrid rows={analytics?.applications ?? []} />
|
||||
</TabsContent>
|
||||
<TabsContent value="protocols">
|
||||
<FlowBreakdownGrid rows={analytics?.protocols ?? []} />
|
||||
</TabsContent>
|
||||
<TabsContent value="sources">
|
||||
<FlowBreakdownGrid rows={analytics?.sources ?? []} />
|
||||
</TabsContent>
|
||||
<TabsContent value="destinations">
|
||||
<FlowBreakdownGrid rows={analytics?.destinations ?? []} />
|
||||
</TabsContent>
|
||||
<TabsContent value="conversations">
|
||||
<TrafficFlowsDataGrid
|
||||
rows={analytics?.conversationsList ?? []}
|
||||
emptyHint={emptyHint}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="interfaces">
|
||||
<FlowBreakdownGrid rows={analytics?.interfaces ?? []} />
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user