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
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:
@@ -64,7 +64,14 @@ function TrafficFlowsDataGrid({
|
||||
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>
|
||||
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||
<span>{row.original.application ?? row.original.protoName}</span>
|
||||
{row.original.category || row.original.service ? (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"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">
|
||||
<Flag code={row.original.toCountry} />
|
||||
{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="Нет рёбер с известной страной" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { FlowTrafficMap }
|
||||
Reference in New Issue
Block a user