feat(traffic-flow): enhance country handling in flow analytics
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 3m26s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s

- Introduced country-specific handling in traffic flow analytics, including new mappings for country nodes and edges.
- Added mock data for countries and their respective service paths to improve visualization in the Network Map.
- Updated the `countryName` function to utilize `Intl.DisplayNames` for better localization of country names.
- Enhanced tests to validate country handling and ensure accurate representation in flow analytics.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-12 11:38:37 +07:00
co-authored by Cursor
parent f3c846201c
commit f15a7348db
9 changed files with 459 additions and 140 deletions
+160 -21
View File
@@ -57,6 +57,7 @@ import {
} from "@/lib/map-netflow-hops"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
import { CountryFlagSvg } from "@/components/network-map/country-flag-svg"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot"
@@ -69,7 +70,7 @@ import {
import { cn } from "@/lib/utils"
import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label"
import Link from "next/link"
import { Flag } from "@/components/flag"
import { Flag, countryName } from "@/components/flag"
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
@@ -285,6 +286,12 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [
{ id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
]
const MOCK_MAP_COUNTRIES: FlowMapService[] = [
{ id: "cc:us", label: "US", category: "Страна", bytes: 22_000_000, bps: 8_800_000, share: 0.38 },
{ id: "cc:nl", label: "NL", category: "Страна", bytes: 14_000_000, bps: 5_600_000, share: 0.31 },
{ id: "cc:de", label: "DE", category: "Страна", bytes: 9_000_000, bps: 3_600_000, share: 0.21 },
]
const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
@@ -293,6 +300,14 @@ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]
const MOCK_MAP_COUNTRY_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "cc:us", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "cc:us", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv2", toId: "cc:nl", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "cc:nl", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv3", toId: "cc:de", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]
const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:google", bytes: 8_000_000, bps: 3_200_000 },
@@ -301,6 +316,33 @@ const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:aws", bytes: 9_000_000, bps: 3_600_000 },
]
const MOCK_MAP_COUNTRY_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us", bytes: 8_000_000, bps: 3_200_000 },
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:nl", bytes: 9_000_000, bps: 3_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:nl", bytes: 5_000_000, bps: 2_000_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:de", bytes: 9_000_000, bps: 3_600_000 },
]
const DEST_MODE_KEY = "mm-network-map-dest-mode"
type DestMode = "services" | "countries"
function readDestMode(): DestMode {
if (typeof window === "undefined") return "services"
try {
return sessionStorage.getItem(DEST_MODE_KEY) === "countries" ? "countries" : "services"
} catch {
return "services"
}
}
function destDisplayLabel(node: FlowMapService | undefined, mode: DestMode, fallback = ""): string {
if (!node) return fallback
if (mode !== "countries") return node.label
if (node.id === "cc:other" || node.label === "Прочее") return "Прочее"
return countryName(node.label)
}
function servicePathKey(p: Pick<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): string {
return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}`
}
@@ -738,6 +780,8 @@ function ServiceNode({
isSel,
isVis,
isDragged,
destMode,
iso,
onClick,
onMouseDown,
}: {
@@ -748,11 +792,14 @@ function ServiceNode({
isSel: boolean
isVis: boolean
isDragged: boolean
destMode: DestMode
iso?: string
onClick: () => void
onMouseDown: (e: React.MouseEvent) => void
}) {
const bw = MAP_SERVICE_NODE_W
const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
return (
<g
transform={`translate(${x},${y})`}
@@ -786,7 +833,9 @@ function ServiceNode({
strokeWidth={isSel ? 2.2 : 1.4}
/>
<g transform="translate(-11,-24)" pointerEvents="none">
<ServiceBrandIcon label={label} size={22} />
{flagIso
? <CountryFlagSvg iso={flagIso} size={22} />
: <ServiceBrandIcon label={destMode === "countries" ? "Прочее" : label} size={22} />}
</g>
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
{label}
@@ -804,6 +853,7 @@ function ServicePathList({
services,
highlight,
viaMode,
destMode,
onToggle,
}: {
paths: FlowMapServicePath[]
@@ -811,6 +861,7 @@ function ServicePathList({
services: FlowMapService[]
highlight: { viaId: string; enId: string; serviceId: string } | null
viaMode: "via" | "service"
destMode: DestMode
onToggle: (p: FlowMapServicePath) => void
}) {
if (paths.length === 0) {
@@ -823,13 +874,14 @@ function ServicePathList({
const via = servers.find((s) => s.id === p.viaId)
const en = servers.find((s) => s.id === p.enId)
const svc = services.find((s) => s.id === p.serviceId)
const destLabel = destDisplayLabel(svc, destMode, p.serviceId)
const label = formatServicePathLabel(p, viaMode, {
viaName: via?.name,
viaSite: via?.site,
enName: en?.name,
serviceLabel: svc?.label,
serviceLabel: destLabel,
})
const title = formatServicePathTitle(label, svc?.label ?? p.serviceId)
const title = formatServicePathTitle(label, destLabel)
const active = Boolean(
highlight
&& highlight.viaId === p.viaId
@@ -1099,11 +1151,15 @@ export default function NetworkMapPage() {
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
const [mapCountries, setMapCountries] = useState<FlowMapService[]>([])
const [mapCountryEdges, setMapCountryEdges] = useState<FlowMapServiceEdge[]>([])
const [mapCountryPaths, setMapCountryPaths] = useState<FlowMapServicePath[]>([])
const [mapSharePct, setMapSharePct] = useState(5)
const [mapNamedBytes, setMapNamedBytes] = useState(0)
const [mapTotalBytes, setMapTotalBytes] = useState(0)
const [mapWindowSec, setMapWindowSec] = useState(300)
const [mapAsnLoaded, setMapAsnLoaded] = useState(true)
const [mapCountryLoaded, setMapCountryLoaded] = useState(true)
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
@@ -1199,9 +1255,13 @@ export default function NetworkMapPage() {
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapSharePct(5)
setMapNamedBytes(0)
setMapTotalBytes(0)
setMapCountryLoaded(true)
setDataError(null)
})
return
@@ -1230,8 +1290,21 @@ export default function NetworkMapPage() {
// ── Interaction ─────────────────────────────────────────────────────────────
const [selected, setSelected] = useState<Server | null>(null)
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
const [destMode, setDestModeState] = useState<DestMode>("services")
useEffect(() => {
queueMicrotask(() => setDestModeState(readDestMode()))
}, [])
function setDestMode(mode: DestMode) {
setDestModeState(mode)
setSelectedService(null)
setHighlightedPath(null)
try { sessionStorage.setItem(DEST_MODE_KEY, mode) } catch { /* private mode */ }
}
const liveSelectedService = selectedService
? (mapServices.find((s) => s.id === selectedService.id) ?? selectedService)
? (
(destMode === "countries" ? mapCountries : mapServices)
.find((s) => s.id === selectedService.id) ?? selectedService
)
: null
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
@@ -1283,7 +1356,11 @@ export default function NetworkMapPage() {
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapSharePct(5)
setMapCountryLoaded(true)
})
return
}
@@ -1293,6 +1370,9 @@ export default function NetworkMapPage() {
setMapServices([])
setMapServiceEdges([])
setMapServicePaths([])
setMapCountries([])
setMapCountryEdges([])
setMapCountryPaths([])
})
return
}
@@ -1308,10 +1388,14 @@ export default function NetworkMapPage() {
setMapServices(res.services ?? [])
setMapServiceEdges(res.serviceEdges ?? [])
setMapServicePaths(res.servicePaths ?? [])
setMapCountries(res.countries ?? [])
setMapCountryEdges(res.countryEdges ?? [])
setMapCountryPaths(res.countryPaths ?? [])
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
setMapNamedBytes(res.namedBytes ?? 0)
setMapTotalBytes(res.totalBytes ?? 0)
if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded)
if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded)
if (res.windowSec) setMapWindowSec(res.windowSec)
})
.catch((err: unknown) => {
@@ -1515,9 +1599,13 @@ export default function NetworkMapPage() {
return m
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
const visibleMapServices = showServices ? mapServices : []
const destNodes = destMode === "countries" ? mapCountries : mapServices
const destEdges = destMode === "countries" ? mapCountryEdges : mapServiceEdges
const destPaths = destMode === "countries" ? mapCountryPaths : mapServicePaths
const visibleMapServices = showServices ? destNodes : []
const visibleServiceEdges = showServices
? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById)
? drawableServiceEdges(visibleMapServices, destEdges, mapServers, greEdges, nodePosById)
: []
const nodes = mapServers
@@ -1756,6 +1844,13 @@ export default function NetworkMapPage() {
}
// ── Side panel ────────────────────────────────────────────────────────────
useEffect(() => {
if (!selectedService) return
if (!destNodes.some((s) => s.id === selectedService.id)) {
setSelectedService(null)
setHighlightedPath(null)
}
}, [destMode, destNodes, selectedService])
function selectServer(s: Server) {
setSelectedGreEdge(null)
setSelectedService(null)
@@ -1919,6 +2014,27 @@ export default function NetworkMapPage() {
)}
</div>
{/* Dest overlay: services vs countries */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "services" as const, label: "Сервисы" },
{ value: "countries" as const, label: "Страны" },
]).map((b) => (
<button
key={b.value}
onClick={() => setDestMode(b.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
destMode === b.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{b.label}
</button>
))}
</div>
{/* Layers dropdown */}
<div className="relative">
<button
@@ -1937,7 +2053,7 @@ export default function NetworkMapPage() {
{([
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
{ key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" },
{ key: "showServices", label: "Сервисы", val: showServices, set: setShowServices, hint: "" },
{ key: "showServices", label: "Назначения", val: showServices, set: setShowServices, hint: "" },
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
@@ -1964,9 +2080,14 @@ export default function NetworkMapPage() {
))}
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
{mapSharePct > 0
? `Порог доли сервиса${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
</p>
{destMode === "countries" && !mapCountryLoaded && (
<p className="px-3 pb-1 text-[10px] text-amber-500 leading-snug">
GeoIP Country не загружен, страны из RIPE-кэша
</p>
)}
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
<div className="border-t border-border/50 mt-1 pt-1">
<button
@@ -2286,13 +2407,15 @@ export default function NetworkMapPage() {
return (
<ServiceNode
key={svc.id}
label={svc.label}
label={destDisplayLabel(svc, destMode)}
share={svc.share}
x={pos.x}
y={pos.y}
isSel={selectedService?.id === svc.id}
isVis
isDragged={draggedSvcId === svc.id}
destMode={destMode}
iso={svc.label}
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
@@ -2336,7 +2459,7 @@ export default function NetworkMapPage() {
const svc = visibleMapServices.find((s) => s.id === edge.toId)
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel}${enName}${svc?.label ?? edge.toId}`
const pathTitle = `${clientLabel}${enName}${destDisplayLabel(svc, destMode, edge.toId)}`
return (
<g
key={`${edge.fromId}|${edge.toId}`}
@@ -2424,7 +2547,9 @@ export default function NetworkMapPage() {
<g transform="translate(10, 164)">
<rect width="14" height="14" rx="4" fill="#08202c" stroke="#22d3ee" strokeWidth="1.2" />
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">Сервис</text>
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">
{destMode === "countries" ? "Страна" : "Сервис"}
</text>
</g>
<line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
@@ -2727,12 +2852,18 @@ export default function NetworkMapPage() {
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
<ServiceBrandIcon label={liveSelectedService.label} size={22} />
{destMode === "countries" && liveSelectedService.label !== "Прочее" && liveSelectedService.id !== "cc:other"
? <Flag code={liveSelectedService.label} size={22} />
: <ServiceBrandIcon label={destMode === "countries" ? "Прочее" : liveSelectedService.label} size={22} />}
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm truncate">{liveSelectedService.label}</p>
<p className="font-mono font-semibold text-sm truncate">
{destDisplayLabel(liveSelectedService, destMode)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Конечный сервис · {liveSelectedService.category}
{destMode === "countries"
? `Конечная страна${liveSelectedService.label !== "Прочее" ? ` · ${liveSelectedService.label}` : ""}`
: `Конечный сервис · ${liveSelectedService.category}`}
</p>
</div>
<button
@@ -2769,12 +2900,18 @@ export default function NetworkMapPage() {
</span>
</div>
)}
{!mapAsnLoaded && (
{!mapAsnLoaded && destMode === "services" && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoLite2 ASN</span>
<span className="text-xs font-mono font-medium text-amber-500">не загружена</span>
</div>
)}
{destMode === "countries" && !mapCountryLoaded && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoIP Country</span>
<span className="text-xs font-mono font-medium text-amber-500">RIPE-кэш</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium">
@@ -2792,7 +2929,7 @@ export default function NetworkMapPage() {
<div className="flex flex-col gap-3">
{visibleServiceEdges.filter((e) => e.toId === liveSelectedService.id).map((e) => {
const src = mapServers.find((s) => s.id === e.fromId)
const enPaths = mapServicePaths
const enPaths = destPaths
.filter((p) => p.serviceId === liveSelectedService.id && p.enId === e.fromId)
.slice()
.sort((a, b) => b.bps - a.bps)
@@ -2807,9 +2944,10 @@ export default function NetworkMapPage() {
<ServicePathList
paths={enPaths}
servers={mapServers}
services={mapServices}
services={destNodes}
highlight={highlightedPath}
viaMode="via"
destMode={destMode}
onToggle={togglePathHighlight}
/>
</div>
@@ -3048,7 +3186,7 @@ export default function NetworkMapPage() {
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
<ServicePathList
paths={mapServicePaths
paths={destPaths
.filter((p) => (
selected.type === "exit-node"
? p.enId === selected.id
@@ -3057,9 +3195,10 @@ export default function NetworkMapPage() {
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={mapServices}
services={destNodes}
highlight={highlightedPath}
viaMode="service"
destMode={destMode}
onToggle={togglePathHighlight}
/>
</div>