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
Починить ISO-страну Cloudflare, не держать holder как сервис, клик по срезу открывает Сессии. Не вызывать REST /interface с UDP, flush SQLite пачкой — uptime не должен ловить timeout 10s. Co-authored-by: Cursor <[email protected]>
192 lines
7.4 KiB
TypeScript
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 }
|