Compare commits

..
2 Commits
Author SHA1 Message Date
DenozordecandCursor f15a7348db 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]>
2026-09-12 11:38:37 +07:00
DenozordecandCursor f3c846201c feat(traffic-flow): enhance Instagram service handling and classification
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m34s
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m25s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Added support for Instagram in traffic flow resolution and classification, including new test cases to validate its behavior.
- Introduced `INSTAGRAM` brand handling in the traffic flow logic, ensuring accurate service identification for Instagram-related traffic.
- Updated existing tests to cover various scenarios involving Instagram, including IP resolution and service categorization.
- Enhanced the service brand icon component to include an Instagram icon for better visual representation.

Co-authored-by: Cursor <[email protected]>
2026-09-12 11:20:16 +07:00
12 changed files with 575 additions and 147 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>
@@ -8,6 +8,7 @@ import {
OTHER_SERVICE,
isNamedInternetService,
mapServiceNodeId,
mapCountryNodeId,
resolveFlowBrand,
resolveRipeCountry,
} from "./traffic-flow-brands.js"
@@ -44,6 +45,11 @@ assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
assert.equal(mapServiceNodeId("Прочее"), "svc:other")
assert.equal(mapCountryNodeId("US"), "cc:us")
assert.equal(mapCountryNodeId("nl"), "cc:nl")
assert.equal(mapCountryNodeId(""), "cc:other")
assert.equal(mapCountryNodeId("Прочее"), "cc:other")
assert.equal(mapCountryNodeId("EU"), "cc:other")
assert.equal(brandByAsn(714)?.service, "Apple")
assert.equal(brandByAsn(714)?.category, "CDN")
@@ -79,6 +85,33 @@ assert.equal(resolveFlowBrand("64.233.161.1", 0, "", 17, 443, 50000)?.service, "
assert.equal(resolveFlowBrand("64.233.161.1", 0, "", 6, 80, 50000)?.service, "Google")
assert.equal(resolveFlowBrand("2001:4860:4860::8888", 15169, "GOOGLE", 17, 53, 53000)?.service, "Google")
assert.equal(resolveFlowBrand("2001:4860:4860::8888", 15169, "GOOGLE", 17, 443, 50000)?.service, "YouTube")
assert.equal(brandByAsn(32934)?.service, "Meta")
assert.equal(lookupBrand("157.240.12.52", 0)?.service, "Meta")
assert.equal(lookupBrand("57.144.22.192", 0)?.service, "Meta")
assert.equal(brandByHolder("Instagram LLC")?.service, "Instagram")
assert.equal(mapServiceNodeId("Instagram"), "svc:instagram")
assert.equal(isNamedInternetService("Instagram", "Видео / стриминг"), true)
assert.equal(
resolveFlowBrand("157.240.12.52", 32934, "FACEBOOK", 6, 443, 51234)?.service,
"Instagram",
"HTTPS на Meta front → Instagram, как YouTube на Google",
)
assert.equal(
resolveFlowBrand("57.144.22.192", 0, "", 17, 443, 50000)?.service,
"Instagram",
"cdninstagram CIDR :443 без ASN → Instagram",
)
assert.equal(
resolveFlowBrand("157.240.12.52", 32934, "FACEBOOK", 6, 80, 50000)?.service,
"Meta",
":80 на Meta остаётся Meta",
)
assert.equal(
resolveFlowBrand("157.240.1.1", 54115, "WHATSAPP", 6, 443, 1)?.service,
"Meta",
"AS54115 WhatsApp не становится Instagram",
)
assert.equal(resolveRipeCountry("", 9059, ""), "IE")
assert.equal(resolveRipeCountry("", 24940, ""), "DE")
+43 -6
View File
@@ -39,6 +39,7 @@ const TIMEWEB: BrandHit = { service: "Timeweb", ...CDN }
const BEGET: BrandHit = { service: "Beget", ...CDN }
const DDOS_GUARD: BrandHit = { service: "DDoS-Guard", ...CDN }
const META: BrandHit = { service: "Meta", ...CDN }
const INSTAGRAM: BrandHit = { service: "Instagram", ...VIDEO }
const GOOGLE: BrandHit = { service: "Google", ...WEB }
const GITHUB: BrandHit = { service: "GitHub", ...WEB }
@@ -184,10 +185,27 @@ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
{ cidr: "216.239.32.0/19", prefixLen: 19, hit: GOOGLE },
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: YOUTUBE },
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
{ cidr: "31.13.64.0/18", prefixLen: 18, hit: META },
{ cidr: "57.141.0.0/16", prefixLen: 16, hit: META },
{ cidr: "57.142.0.0/15", prefixLen: 15, hit: META },
{ cidr: "57.144.0.0/14", prefixLen: 14, hit: META },
{ cidr: "57.148.0.0/15", prefixLen: 15, hit: META },
{ cidr: "66.220.144.0/20", prefixLen: 20, hit: META },
{ cidr: "69.63.176.0/20", prefixLen: 20, hit: META },
{ cidr: "69.171.224.0/19", prefixLen: 19, hit: META },
{ cidr: "74.119.76.0/22", prefixLen: 22, hit: META },
{ cidr: "129.134.0.0/16", prefixLen: 16, hit: META },
{ cidr: "157.240.0.0/16", prefixLen: 16, hit: META },
{ cidr: "173.252.64.0/18", prefixLen: 18, hit: META },
{ cidr: "179.60.192.0/22", prefixLen: 22, hit: META },
{ cidr: "185.60.216.0/22", prefixLen: 22, hit: META },
{ cidr: "199.201.64.0/22", prefixLen: 22, hit: META },
{ cidr: "204.15.20.0/22", prefixLen: 22, hit: META },
].sort((a, b) => b.prefixLen - a.prefixLen)
const HOLDER_BRANDS: Array<{ re: RegExp; hit: BrandHit }> = [
{ re: /youtube/i, hit: YOUTUBE },
{ re: /instagram/i, hit: INSTAGRAM },
{ re: /valve|\bsteam\b/i, hit: STEAM },
{ re: /blizzard|battle.?net/i, hit: BLIZZARD },
{ re: /openai/i, hit: CHATGPT },
@@ -205,6 +223,9 @@ const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
const STEAM_ASN = 32590
const GOOGLE_FRONT_ASN = new Set([15169, 396982])
/** AS32934 / AS63293 — Meta front (Facebook + Instagram CDN). AS54115 — WhatsApp, не Instagram. */
const META_FRONT_ASN = new Set([32934, 63293])
const WHATSAPP_ASN = 54115
function isGooglePublicDns(ip: string): boolean {
return ipInCidrV4(ip, "8.8.8.0/24") || ipInCidrV4(ip, "8.8.4.0/24")
@@ -274,9 +295,19 @@ export function lookupBrand(ip: string, asn: number): BrandHit | null {
return brandByCidr(ip) || brandByAsn(asn)
}
function isGoogleFront(asn: number, cidrBrand: BrandHit | null, asnBrand: BrandHit | null): boolean {
return GOOGLE_FRONT_ASN.has(asn) || cidrBrand?.service === "Google" || asnBrand?.service === "Google"
}
function isInstagramFront(asn: number, cidrBrand: BrandHit | null, asnBrand: BrandHit | null): boolean {
if (asn === WHATSAPP_ASN) return false
return META_FRONT_ASN.has(asn) || cidrBrand?.service === "Meta" || asnBrand?.service === "Meta"
}
/**
* Cloudflare CIDR бьёт holder (витрина на CF не становится Steam).
* Holder (YouTube и др.) бьёт остальные CIDR/ASN.
* Holder (YouTube / Instagram и др.) бьёт остальные CIDR/ASN.
* HTTPS/QUIC на Google front → YouTube (кроме 8.8.8.8); на Meta front → Instagram (кроме WhatsApp ASN).
* Порты Steam — только AS32590 и не выше Cloudflare CIDR.
*/
export function resolveFlowBrand(
@@ -292,13 +323,12 @@ export function resolveFlowBrand(
const holderBrand = brandByHolder(holder)
if (holderBrand) return holderBrand
const asnBrand = brandByAsn(asn)
if (
!isGooglePublicDns(ip)
&& isHttpsOrQuic(proto, dstPort, srcPort)
&& (GOOGLE_FRONT_ASN.has(asn) || cidrBrand?.service === "Google" || asnBrand?.service === "Google")
) {
if (!isGooglePublicDns(ip) && isHttpsOrQuic(proto, dstPort, srcPort) && isGoogleFront(asn, cidrBrand, asnBrand)) {
return YOUTUBE
}
if (isHttpsOrQuic(proto, dstPort, srcPort) && isInstagramFront(asn, cidrBrand, asnBrand)) {
return INSTAGRAM
}
const fromLookup = cidrBrand || asnBrand
if (fromLookup) return fromLookup
if (asn === STEAM_ASN && isSteamGamePort(proto, dstPort, srcPort)) return STEAM
@@ -334,3 +364,10 @@ export function mapServiceNodeId(label: string): string {
.replace(/^-+|-+$/g, "")
return `svc:${slug || "unknown"}`
}
/** `US` → `cc:us`; неизвестная / пустая → `cc:other`. */
export function mapCountryNodeId(code: string): string {
const iso = normalizeIsoCountry(code)
if (!iso) return "cc:other"
return `cc:${iso.toLowerCase()}`
}
@@ -155,6 +155,50 @@ const ipv6Yt = classifyFlowDst("2001:4860:4860::8888", 17, 443, 50000, {
fetchedAt: Date.now(),
})
assert.equal(ipv6Yt.service, "YouTube")
const instagram = classifyFlowDst("157.240.12.52", 6, 443, 62598, {
prefix: "157.240.0.0/16",
asn: 32934,
country: "US",
lat: null,
lng: null,
holder: "FACEBOOK",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(instagram.service, "Instagram")
assert.equal(instagram.category, "Видео / стриминг")
assert.notEqual(instagram.service, "Meta")
const metaHttp = classifyFlowDst("157.240.12.52", 6, 80, 50000, {
prefix: "157.240.0.0/16",
asn: 32934,
country: "US",
lat: null,
lng: null,
holder: "FACEBOOK",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(metaHttp.service, "Meta")
assert.equal(metaHttp.category, "CDN")
const igHolder = classifyFlowDst("203.0.113.80", 6, 443, 1, {
prefix: "203.0.113.0/24",
asn: 64503,
country: "US",
lat: null,
lng: null,
holder: "Instagram LLC",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(igHolder.service, "Instagram")
const igCidr = classifyFlowDst("57.144.22.192", 17, 443, 50000, null)
assert.equal(igCidr.service, "Instagram")
assert.equal(igCidr.category, "Видео / стриминг")
const esp = classifyFlowDst("198.51.100.1", 50, 0, 0, null)
assert.equal(esp.category, "Туннель")
assert.equal(applicationName(17, 443, 50000), "QUIC")
@@ -48,7 +48,7 @@ export function seedFlowCatalogForTests(input: {
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
const p = purpose.toLowerCase()
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
if (/streaming|youtube|netflix|twitch|video|spotify/.test(p)) return "Видео / стриминг"
if (/streaming|youtube|netflix|twitch|video|spotify|instagram/.test(p)) return "Видео / стриминг"
if (/cdn|cloudflare|akamai|fastly|hetzner|ovh|apple/.test(p)) return "CDN"
if (/voip|discord|zoom/.test(p)) return "Голос"
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
@@ -306,6 +306,15 @@ try {
assert.ok(googleEdge)
assert.equal(googleEdge.clientName, "Alice")
assert.equal((six.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), 10_000)
const us = six.countries?.find((s) => s.id === "cc:us")
assert.ok(us, "Google ripe country US")
assert.equal(us.label, "US")
const usEdge = six.countryEdges?.find((e) => e.toId === "cc:us" && e.fromId === "9")
assert.ok(usEdge)
assert.ok(!(six.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")), "страны не смешиваются с svc:*")
const usPath = six.countryPaths?.find((p) => p.serviceId === "cc:us" && p.enId === "9")
assert.ok(usPath)
assert.equal(usPath.clientName, "Alice")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
@@ -810,4 +819,51 @@ try {
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
seedRipeCacheForTests({
prefix: "185.45.12.0/24",
asn: 13335,
country: "NL",
lat: 52.3,
lng: 4.9,
holder: "CLOUDFLARENET, NL",
ok: true,
fetchedAt: Date.now(),
})
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 5000),
payloadFlow("185.45.12.10", 5000),
])
try {
resetFlowMapHopsCacheForTests()
const split = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const us = split.countries?.find((s) => s.id === "cc:us")
const nl = split.countries?.find((s) => s.id === "cc:nl")
assert.ok(us, "US из ripe Google")
assert.ok(nl, "NL из ripe Cloudflare")
assert.equal(us.bytes, 5000)
assert.equal(nl.bytes, 5000)
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:us" && e.fromId === "9"))
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:nl" && e.fromId === "9"))
assert.ok(!(split.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")))
assert.ok(split.serviceEdges?.some((e) => e.toId === "svc:google"))
assert.ok(!(split.serviceEdges ?? []).some((e) => e.toId.startsWith("cc:")))
assert.ok(split.countryPaths?.some((p) => p.serviceId === "cc:nl" && p.enId === "9"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: ok")
+180 -116
View File
@@ -3,7 +3,8 @@ import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, Fl
import { db } from "../db/index.js"
import { userInterfaceBindings } from "../db/schema.js"
import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { OTHER_SERVICE, isNamedInternetService, mapServiceNodeId } from "./traffic-flow-brands.js"
import { OTHER_SERVICE, isNamedInternetService, mapCountryNodeId, mapServiceNodeId, resolveRipeCountry } from "./traffic-flow-brands.js"
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
import { refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
@@ -20,6 +21,7 @@ export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
export const MAP_SERVICE_NODE_CAP = 20
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
export const MAP_SERVICE_MIN_NODES = 8
export const MAP_COUNTRY_CATEGORY = "Страна"
const HOPS_CACHE_TTL_MS = 2000
export interface FlowMapHopsQuery {
@@ -64,6 +66,32 @@ interface DstAcc {
fromBytes: Map<string, FromAcc>
}
interface DestTotal {
label: string
category: string
bytes: number
}
interface DestEdgeAcc {
fromId: string
toId: string
bytes: number
bytesFwd: number
bytesRev: number
clients: Map<string, string>
}
interface DestPathAcc {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}
function bumpClient(clients: Map<string, ClientAcc>, bytes: number, client: { userId: string; name: string } | null): void {
const id = client?.userId || "—"
const name = client?.name || "—"
@@ -108,6 +136,137 @@ export function pickMapServices(ranked: FlowMapService[], minSharePct: number):
.slice(0, MAP_SERVICE_NODE_CAP)
}
function bumpDestTotal(totals: Map<string, DestTotal>, id: string, label: string, category: string, bytes: number): void {
const prev = totals.get(id)
if (prev) {
prev.bytes += bytes
return
}
totals.set(id, { label, category, bytes })
}
function bumpDestFrom(
edges: Map<string, DestEdgeAcc>,
paths: Map<string, DestPathAcc>,
toId: string,
from: FromAcc,
exporterId: string,
fromId: string,
enName: string,
viaName: string,
): void {
const edgeKey = `${fromId}|${toId}`
const prevEdge = edges.get(edgeKey)
const namedClients = new Map<string, string>()
for (const [id, c] of from.clients) {
if (id !== "—") namedClients.set(id, c.name)
}
if (prevEdge) {
prevEdge.bytes += from.bytes
prevEdge.bytesFwd += from.bytes
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
} else {
edges.set(edgeKey, {
fromId,
toId,
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: namedClients,
})
}
for (const [clientId, c] of from.clients) {
const pathKey = `${clientId}|${fromId}|${toId}`
const prevPath = paths.get(pathKey)
if (prevPath) {
prevPath.bytes += c.bytes
if (exporterId !== fromId && prevPath.viaId === fromId) {
prevPath.viaId = exporterId
prevPath.viaName = viaName
}
if (prevPath.clientName === "—" && c.name !== "—") prevPath.clientName = c.name
} else {
paths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
}
function finalizeDestLayer(
totals: Map<string, DestTotal>,
edges: Map<string, DestEdgeAcc>,
paths: Map<string, DestPathAcc>,
windowSec: number,
minSharePct: number,
shareBase: number,
): { nodes: FlowMapService[]; edges: FlowMapServiceEdge[]; paths: FlowMapServicePath[] } {
const nodes = pickMapServices(
[...totals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: shareBase > 0 ? s.bytes / shareBase : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
minSharePct,
)
const keep = new Set(nodes.map((s) => s.id))
const outEdges: FlowMapServiceEdge[] = [...edges.values()]
.filter((e) => keep.has(e.toId))
.map((e) => {
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
const first = clients[0]
return {
fromId: e.fromId,
toId: e.toId,
bytes: e.bytes,
bps: (e.bytes * 8) / windowSec,
bpsFwd: (e.bytesFwd * 8) / windowSec,
bpsRev: (e.bytesRev * 8) / windowSec,
...(first ? { clientId: first.id, clientName: first.name } : {}),
...(clients.length ? { clients } : {}),
}
})
.sort((a, b) => b.bytes - a.bytes)
const outPaths: FlowMapServicePath[] = [...paths.values()]
.filter((p) => keep.has(p.serviceId))
.map((p) => ({
clientId: p.clientId,
clientName: p.clientName,
viaId: p.viaId,
viaName: p.viaName,
enId: p.enId,
enName: p.enName,
serviceId: p.serviceId,
bytes: p.bytes,
bps: (p.bytes * 8) / windowSec,
}))
.sort((a, b) => b.bps - a.bps)
return { nodes, edges: outEdges, paths: outPaths }
}
function countryDestFromRipe(ripe: FlowIpMeta | null, destKey: string): { id: string; label: string; category: string } {
if (!destKey || destKey === "__other__" || !ripe?.ok) {
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
}
const iso = resolveRipeCountry(ripe.country, ripe.asn, ripe.holder)
if (!iso) {
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
}
return { id: mapCountryNodeId(iso), label: iso, category: MAP_COUNTRY_CATEGORY }
}
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
return JSON.stringify({
epoch: flowDataEpoch(),
@@ -368,25 +527,12 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
}
}
const svcTotals = new Map<string, { label: string; category: string; bytes: number }>()
const svcEdges = new Map<string, {
fromId: string
toId: string
bytes: number
bytesFwd: number
bytesRev: number
clients: Map<string, string>
}>()
const svcPaths = new Map<string, {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}>()
const svcTotals = new Map<string, DestTotal>()
const svcEdges = new Map<string, DestEdgeAcc>()
const svcPaths = new Map<string, DestPathAcc>()
const ccTotals = new Map<string, DestTotal>()
const ccEdges = new Map<string, DestEdgeAcc>()
const ccPaths = new Map<string, DestPathAcc>()
for (const h of hops.values()) {
if (h.kind !== "gre" || !h.toId) continue
@@ -423,58 +569,17 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
const classified = dst && dst !== "__other__"
? mapInternetBrand(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
: { service: OTHER_SERVICE, category: OTHER_SERVICE }
const toId = mapServiceNodeId(classified.service)
const prevSvc = svcTotals.get(toId)
if (prevSvc) prevSvc.bytes += acc.bytes
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: acc.bytes })
const svcId = mapServiceNodeId(classified.service)
bumpDestTotal(svcTotals, svcId, classified.service, classified.category, acc.bytes)
const country = countryDestFromRipe(ripe, dst)
bumpDestTotal(ccTotals, country.id, country.label, country.category, acc.bytes)
for (const [exporterId, from] of acc.fromBytes) {
const fromId = anchorEnId(exporterId)
if (!fromId) continue
const edgeKey = `${fromId}|${toId}`
const prevEdge = svcEdges.get(edgeKey)
const namedClients = new Map<string, string>()
for (const [id, c] of from.clients) {
if (id !== "—") namedClients.set(id, c.name)
}
if (prevEdge) {
prevEdge.bytes += from.bytes
prevEdge.bytesFwd += from.bytes
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
} else {
svcEdges.set(edgeKey, {
fromId,
toId,
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: namedClients,
})
}
const enName = nodeName(fromId)
const viaName = nodeName(exporterId)
for (const [clientId, c] of from.clients) {
const pathKey = `${clientId}|${fromId}|${toId}`
const prevPath = svcPaths.get(pathKey)
if (prevPath) {
prevPath.bytes += c.bytes
if (exporterId !== fromId && prevPath.viaId === fromId) {
prevPath.viaId = exporterId
prevPath.viaName = viaName
}
if (prevPath.clientName === "—" && c.name !== "—") prevPath.clientName = c.name
} else {
svcPaths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
bumpDestFrom(svcEdges, svcPaths, svcId, from, exporterId, fromId, enName, viaName)
bumpDestFrom(ccEdges, ccPaths, country.id, from, exporterId, fromId, enName, viaName)
}
}
@@ -483,52 +588,8 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
.reduce((n, s) => n + s.bytes, 0)
const unclassifiedBytes = Math.max(0, totalBytes - namedBytes)
const shareBase = totalBytes > 0 ? totalBytes : namedBytes
const services = pickMapServices(
[...svcTotals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: shareBase > 0 ? s.bytes / shareBase : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
minSharePct,
)
const keepSvc = new Set(services.map((s) => s.id))
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
.filter((e) => keepSvc.has(e.toId))
.map((e) => {
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
const first = clients[0]
return {
fromId: e.fromId,
toId: e.toId,
bytes: e.bytes,
bps: (e.bytes * 8) / windowSec,
bpsFwd: (e.bytesFwd * 8) / windowSec,
bpsRev: (e.bytesRev * 8) / windowSec,
...(first ? { clientId: first.id, clientName: first.name } : {}),
...(clients.length ? { clients } : {}),
}
})
.sort((a, b) => b.bytes - a.bytes)
const servicePaths: FlowMapServicePath[] = [...svcPaths.values()]
.filter((p) => keepSvc.has(p.serviceId))
.map((p) => ({
clientId: p.clientId,
clientName: p.clientName,
viaId: p.viaId,
viaName: p.viaName,
enId: p.enId,
enName: p.enName,
serviceId: p.serviceId,
bytes: p.bytes,
bps: (p.bytes * 8) / windowSec,
}))
.sort((a, b) => b.bps - a.bps)
const servicesOut = finalizeDestLayer(svcTotals, svcEdges, svcPaths, windowSec, minSharePct, shareBase)
const countriesOut = finalizeDestLayer(ccTotals, ccEdges, ccPaths, windowSec, minSharePct, shareBase)
const listener = getFlowListenerState()
const geo = geoipReadersStatus()
@@ -544,9 +605,12 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
unclassifiedBytes,
asnLoaded: geo.asnLoaded,
countryLoaded: geo.countryLoaded,
services,
serviceEdges,
servicePaths,
services: servicesOut.nodes,
serviceEdges: servicesOut.edges,
servicePaths: servicesOut.paths,
countries: countriesOut.nodes,
countryEdges: countriesOut.edges,
countryPaths: countriesOut.paths,
mapServiceMinSharePct: minSharePct,
dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh,
+29 -2
View File
@@ -20,9 +20,29 @@ const COUNTRY_NAMES: Record<string, string> = {
HK: "Гонконг",
}
/** Country name in Russian (fallback to code) */
let regionNames: Intl.DisplayNames | null | undefined
function regionDisplayName(iso: string): string | undefined {
try {
if (regionNames === undefined) {
regionNames = typeof Intl !== "undefined" && "DisplayNames" in Intl
? new Intl.DisplayNames(["ru"], { type: "region" })
: null
}
return regionNames?.of(iso) ?? undefined
} catch {
return undefined
}
}
/** Country name in Russian (fallback to ISO code) */
export function countryName(code: string): string {
return COUNTRY_NAMES[code.toUpperCase()] ?? code
const iso = code.toUpperCase()
if (!iso) return code
if (COUNTRY_NAMES[iso]) return COUNTRY_NAMES[iso]
const intl = regionDisplayName(iso)
if (intl && intl !== iso) return intl
return iso
}
interface FlagProps {
@@ -39,6 +59,13 @@ function nearestCdnSize(px: number): number {
return CDN_SIZES.find(s => s >= px) ?? CDN_SIZES[CDN_SIZES.length - 1]
}
/** CDN URL for SVG `<image href>` (flagcdn widths only). */
export function flagCdnUrl(code: string, size = 40): string | null {
const lower = code.toLowerCase()
if (!/^[a-z]{2}$/.test(lower)) return null
return `https://flagcdn.com/w${nearestCdnSize(size)}/${lower}.png`
}
/**
* Renders a flag <img> for a given ISO 3166-1 alpha-2 country code.
* Source: https://flagcdn.com — free CDN, no API key needed.
@@ -0,0 +1,17 @@
"use client"
import { flagCdnUrl } from "@/components/flag"
export function CountryFlagSvg({ iso, size = 22 }: { iso: string; size?: number }) {
const url = flagCdnUrl(iso, size)
if (!url) return null
const h = Math.round(size * 0.75)
return (
<image
href={url}
width={size}
height={h}
preserveAspectRatio="xMidYMid meet"
/>
)
}
@@ -74,6 +74,14 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
<path d="M10.2 9.2v5.6L15.6 12Z" fill="#fff" />
</BrandSvg>
)
case "instagram":
return (
<BrandSvg size={size}>
<rect x="3" y="3" width="18" height="18" rx="5" fill="#E4405F" />
<circle cx="12" cy="12.2" r="4.1" fill="none" stroke="#fff" strokeWidth="1.8" />
<circle cx="16.3" cy="7.7" r="1.15" fill="#fff" />
</BrandSvg>
)
case "netflix":
return (
<BrandSvg size={size}>
+3
View File
@@ -329,6 +329,9 @@ export const flowMapHopsDtoSchema = z.object({
services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
countries: z.array(flowMapServiceDtoSchema).optional(),
countryEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
countryPaths: z.array(flowMapServicePathDtoSchema).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(),
+1 -1
View File
File diff suppressed because one or more lines are too long