Files
MikrotikManager/components/traffic/flow-traffic-map.tsx
T
DenozordecandCursor e0ddb17539
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 1m48s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
feat(traffic): классифицировать потоки по брендам и разгрузить ingest
Починить ISO-страну Cloudflare, не держать holder как сервис, клик по срезу открывает Сессии. Не вызывать REST /interface с UDP, flush SQLite пачкой — uptime не должен ловить timeout 10s.

Co-authored-by: Cursor <[email protected]>
2026-09-07 03:01:14 +07:00

192 lines
7.4 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import type { FlowMapEdge } from "@mmapp/contracts/traffic-flow"
import { Frame, FramePanel } from "@/components/reui/frame"
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 { Flag } from "@/components/flag"
import { fmtRate } from "@/lib/fmt-rate"
const W = 640
const H = 280
const STROKES = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--color-success)",
"var(--chart-5)",
]
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} Б`
}
function FlowTrafficMap({
edges,
onSelectCountry,
}: {
edges: FlowMapEdge[]
onSelectCountry?: (country: string) => void
}) {
const [hover, setHover] = useState<string | null>(null)
const layout = useMemo(() => {
const sources = [...new Map(edges.map((e) => [e.fromId, e])).values()]
const dests = [...new Map(edges.map((e) => [e.toCountry, e])).values()]
const srcY = (i: number) => sources.length <= 1 ? H / 2 : 36 + (i * (H - 72)) / Math.max(1, sources.length - 1)
const dstY = (i: number) => dests.length <= 1 ? H / 2 : 36 + (i * (H - 72)) / Math.max(1, dests.length - 1)
const srcPos = new Map(sources.map((s, i) => [s.fromId, { x: 88, y: srcY(i), label: s.fromLabel, country: s.fromCountry }]))
const dstPos = new Map(dests.map((d, i) => [d.toCountry, { x: 552, y: dstY(i), country: d.toCountry }]))
const maxBytes = Math.max(...edges.map((e) => e.bytes), 1)
return { srcPos, dstPos, maxBytes }
}, [edges])
const columns = useMemo<ColumnDef<FlowMapEdge>[]>(
() => [
{
id: "from",
accessorKey: "fromLabel",
header: () => <span className="text-xs font-medium text-muted-foreground">Источник</span>,
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
{row.original.fromCountry && row.original.fromCountry !== "UN"
? <Flag code={row.original.fromCountry} />
: null}
{row.original.fromLabel}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "to",
accessorKey: "toCountry",
header: () => <span className="text-xs font-medium text-muted-foreground">Назначение</span>,
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
{/^[a-z]{2}$/i.test(row.original.toCountry)
? <Flag code={row.original.toCountry} />
: null}
{row.original.toCountry}
{row.original.toAsn ? <span className="font-mono text-[10px] text-muted-foreground">AS{row.original.toAsn}</span> : null}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "cat",
accessorKey: "category",
header: () => <span className="text-xs font-medium text-muted-foreground">Категория</span>,
cell: ({ row }) => <span className="text-xs">{row.original.category}</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: edges,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row, i) => `${row.fromId}-${row.toCountry}-${i}`,
})
if (!edges.length) {
return (
<p className="text-sm text-muted-foreground py-8 text-center">
Страны подтянутся из кэша RIPEstat
</p>
)
}
return (
<div className="flex flex-col gap-3">
<Frame>
<FramePanel className="p-3">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-[220px]" role="img" aria-label="Карта потоков откуда куда">
{edges.map((e, i) => {
const from = layout.srcPos.get(e.fromId)
const to = layout.dstPos.get(e.toCountry)
if (!from || !to) return null
const id = `${e.fromId}-${e.toCountry}`
const midX = (from.x + to.x) / 2
const d = `M ${from.x} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${to.x} ${to.y}`
const sw = 1.25 + 7 * (e.bytes / layout.maxBytes)
const active = hover === id
return (
<path
key={id}
d={d}
fill="none"
stroke={STROKES[i % STROKES.length]}
strokeWidth={active ? sw + 1.5 : sw}
strokeOpacity={active ? 1 : 0.72}
className="cursor-pointer"
onMouseEnter={() => setHover(id)}
onMouseLeave={() => setHover(null)}
onClick={() => onSelectCountry?.(e.toCountry)}
>
<title>
{`${e.fromLabel}${e.toCountry} · ${e.category} · ${formatBytes(e.bytes)}`}
</title>
</path>
)
})}
{[...layout.srcPos.values()].map((n) => (
<g key={`s-${n.label}`}>
<circle cx={n.x} cy={n.y} r="7" className="fill-primary" />
<text x={n.x - 14} y={n.y + 4} textAnchor="end" className="fill-foreground text-[11px]">
{n.label}
</text>
</g>
))}
{[...layout.dstPos.values()].map((n) => (
<g key={`d-${n.country}`}>
<circle cx={n.x} cy={n.y} r="7" className="fill-chart-2" />
<text x={n.x + 14} y={n.y + 4} className="fill-foreground text-[11px]">
{n.country}
</text>
</g>
))}
</svg>
{hover ? (
<p className="text-[11px] text-muted-foreground mt-1">
Нажмите дугу, чтобы отфильтровать сессии по стране назначения
</p>
) : null}
</FramePanel>
</Frame>
<DataGridShell
table={table}
recordCount={edges.length}
emptyMessage="Нет рёбер с известной страной"
onRowClick={(row) => onSelectCountry?.(row.toCountry)}
/>
</div>
)
}
export { FlowTrafficMap }