From db64621122b634ecf5b0e2942ce629a868d2e53f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 7 Sep 2026 13:30:45 +0700 Subject: [PATCH] =?UTF-8?q?feat(network-map):=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D0=B5=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=B5=20=D1=81=D0=B5?= =?UTF-8?q?=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetFlow ≥ 5% окна, узлы с логотипом бренда справа от EN, скорость потока на рёбрах к сервисам. Co-authored-by: Cursor --- app/(main)/network-map/page.tsx | 306 +++++++++++++++++- .../src/services/traffic-flow-brands.test.ts | 10 + backend/src/services/traffic-flow-brands.ts | 35 +- .../services/traffic-flow-map-hops.test.ts | 116 +++++++ backend/src/services/traffic-flow-map-hops.ts | 73 ++++- components/network-map/service-brand-icon.tsx | 131 ++++++++ lib/network-map-layout.ts | 31 +- packages/contracts/src/traffic-flow.ts | 23 ++ 8 files changed, 703 insertions(+), 22 deletions(-) create mode 100644 components/network-map/service-brand-icon.tsx diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index fa4d7a9..6334243 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -17,11 +17,14 @@ import { buildServerResourceMap, buildWanJhEdges, computeNetworkMapLayout, + NETWORK_MAP_H, NETWORK_MAP_LAYOUT_REVISION, NETWORK_MAP_PIPELINE_Y, + NETWORK_MAP_W, findServerByGreRemote, greSourceWanIndexOnMap, greTunnelProbe, + placeServiceNodes, type GreMapEdge, type WanJhEdge, } from "@/lib/network-map-layout" @@ -49,7 +52,8 @@ import { matchNetflowForWan, type MatchedNetflowHop, } from "@/lib/map-netflow-hops" -import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow" +import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow" +import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon" import { Button } from "@/components/ui/button" import { StatusBadge } from "@/components/status-badge" import { StatusDot } from "@/components/status-dot" @@ -235,8 +239,8 @@ function Sparkline({ history }: { history: number[] }) { // ─── Canvas dimensions ──────────────────────────────────────────────────────── -const W = 1060 -const H = 580 +const W = NETWORK_MAP_W +const H = NETWORK_MAP_H const ZOOM_MIN = 0.2 const ZOOM_MAX = 6 @@ -271,6 +275,24 @@ const TYPE_LABELS: Record = { "home-router": "Home Router", } +const MOCK_MAP_SERVICES: FlowMapService[] = [ + { id: "svc:google", label: "Google", category: "Веб", bytes: 22_000_000, bps: 8_800_000, share: 0.22 }, + { id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 14_000_000, bps: 5_600_000, share: 0.14 }, + { id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 }, +] + +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 }, + { fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000 }, + { fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000 }, + { fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000 }, + { fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000 }, +] + +function serviceSharePct(share: number): string { + return `${Math.round(share * 100)}%` +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function pingColor(ms: number | null) { @@ -608,6 +630,74 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o ) } +function ServiceNode({ + label, + share, + x, + y, + isSel, + isVis, + onClick, +}: { + label: string + share: number + x: number + y: number + isSel: boolean + isVis: boolean + onClick: () => void +}) { + const bw = 86 + const bh = 58 + return ( + { e.stopPropagation(); onClick() }} + > + {`${label} · ${serviceSharePct(share)} трафика окна`} + {isSel && ( + + )} + + +
)} + > + +
+
+ + {label} + + + {serviceSharePct(share)} + +
+ ) +} + function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: { x: number; y: number wan: { name: string; isp: string; maxDl: number; maxUl: number } @@ -723,13 +813,14 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void }) const MM_W = 172, MM_H = 94 -function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, onClose, onPan }: { +function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: { pan: { x: number; y: number }; zoom: number nodes: (Server & { x: number; y: number })[] greEdges: GreMapEdge[] satPos: Record wanJhEdges: WanJhEdge[] homeRouters: Server[] + servicePos: Record onClose: () => void onPan: (x: number, y: number) => void }) { @@ -782,6 +873,10 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, fill={WAN_COLORS[i] + "33"} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" /> )) )} + {Object.entries(servicePos).map(([id, p]) => ( + + ))} {/* viewport rect */} @@ -841,6 +936,8 @@ export default function NetworkMapPage() { const [mapGreTunnels, setMapGreTunnels] = useState([]) const [speedProbes, setSpeedProbes] = useState([]) const [mapHops, setMapHops] = useState([]) + const [mapServices, setMapServices] = useState([]) + const [mapServiceEdges, setMapServiceEdges] = useState([]) /** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */ const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState>({}) const [dataError, setDataError] = useState(null) @@ -932,6 +1029,9 @@ export default function NetworkMapPage() { setMapGreTunnels(mockGreTunnels) setSpeedProbes([]) setGreResolvedIpv4ByHost({}) + setMapHops([]) + setMapServices(MOCK_MAP_SERVICES) + setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) setDataError(null) }) return @@ -959,6 +1059,7 @@ export default function NetworkMapPage() { // ── Interaction ───────────────────────────────────────────────────────────── const [selected, setSelected] = useState(null) + const [selectedService, setSelectedService] = useState(null) const [selWanIdx, setSelWanIdx] = useState(null) const [hoveredId, setHoveredId] = useState(null) @@ -993,21 +1094,44 @@ export default function NetworkMapPage() { const [search, setSearch] = useState("") const [showPingBadges, setShowPingBadges] = useState(true) const [showNetflow, setShowNetflow] = useState(true) + const [showServices, setShowServices] = useState(true) const [showAnimDots, setShowAnimDots] = useState(true) const [showMinimap, setShowMinimap] = useState(true) const [showHints, setShowHints] = useState(false) const [showLayers, setShowLayers] = useState(false) useEffect(() => { - if (!useLiveData || !showNetflow) { - queueMicrotask(() => setMapHops([])) + if (!useLiveData) { + queueMicrotask(() => { + setMapHops([]) + setMapServices(MOCK_MAP_SERVICES) + setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) + }) + return + } + if (!showNetflow && !showServices) { + queueMicrotask(() => { + setMapHops([]) + setMapServices([]) + setMapServiceEdges([]) + }) return } let cancelled = false const tick = () => { apiFetch("/api/traffic/flow/map-hops?range=5m") - .then((res) => { if (!cancelled) setMapHops(res.hops ?? []) }) - .catch(() => { if (!cancelled) setMapHops([]) }) + .then((res) => { + if (cancelled) return + setMapHops(res.hops ?? []) + setMapServices(res.services ?? []) + setMapServiceEdges(res.serviceEdges ?? []) + }) + .catch(() => { + if (cancelled) return + setMapHops([]) + setMapServices([]) + setMapServiceEdges([]) + }) } tick() const id = window.setInterval(tick, 4000) @@ -1015,7 +1139,7 @@ export default function NetworkMapPage() { cancelled = true window.clearInterval(id) } - }, [useLiveData, showNetflow, apiFetch]) + }, [useLiveData, showNetflow, showServices, apiFetch]) const effectiveSatPos = useMemo(() => { const out: Record = {} @@ -1203,6 +1327,11 @@ export default function NetworkMapPage() { return m }, [homeRouters, wanJhEdges, mapHops, showNetflow]) + const visibleMapServices = showServices ? mapServices : [] + const visibleServiceEdges = showServices ? mapServiceEdges.filter((e) => + visibleMapServices.some((s) => s.id === e.toId), + ) : [] + const nodes = mapServers .map((s) => ({ ...s, ...nodePosById[s.id]! })) // Визуальный приоритет: HR поверх JH, JH поверх EN. @@ -1215,6 +1344,14 @@ export default function NetworkMapPage() { }) const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n])) + const servicePosById = placeServiceNodes( + visibleMapServices.map((s) => s.id), + mapServers + .filter((s) => s.type === "exit-node") + .map((s) => nodePosById[s.id]) + .filter((p): p is { x: number; y: number } => Boolean(p)), + ) + // ── Refs ───────────────────────────────────────────────────────────────────── const svgRef = useRef(null) @@ -1289,7 +1426,7 @@ export default function NetworkMapPage() { useEffect(() => { function onKey(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return - if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) } + if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) } if (e.key === "=" || e.key === "+") applyZoomCenter(1.25) if (e.key === "-") applyZoomCenter(1 / 1.25) if (e.key === "0" || e.key.toLowerCase() === "f") fitView() @@ -1377,7 +1514,7 @@ export default function NetworkMapPage() { const moved = dragRef.current?.moved ?? false dragRef.current = null setIsDragging(false) - if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) } + if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) } } // ── Node drag start ────────────────────────────────────────────────────── @@ -1409,12 +1546,20 @@ export default function NetworkMapPage() { // ── Side panel ──────────────────────────────────────────────────────────── function selectServer(s: Server) { setSelectedGreEdge(null) + setSelectedService(null) setSelected(prev => prev?.id === s.id ? null : s) setSelWanIdx(null) setHoveredId(null) } + function selectService(svc: FlowMapService) { + setSelectedGreEdge(null) + setSelected(null) + setSelWanIdx(null) + setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc) + } function selectWan(s: Server, wanIdx: number) { setSelectedGreEdge(null) + setSelectedService(null) setSelected(s) setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx) } @@ -1424,6 +1569,7 @@ export default function NetworkMapPage() { const home = mapServers.find((s) => s.id === edge.homeId) if (!home) return setSelectedGreEdge(null) + setSelectedService(null) setSelected(home) setSelWanIdx(edge.wanIdx) setHoveredId(null) @@ -1565,6 +1711,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: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" }, { key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" }, { key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" }, @@ -1705,6 +1852,7 @@ export default function NetworkMapPage() { ev.stopPropagation() setSelectedGreEdge(e) setSelected(null) + setSelectedService(null) setSelWanIdx(null) } return ( @@ -1845,6 +1993,50 @@ export default function NetworkMapPage() { ) })} + {/* ── EN/JH → destination services ── */} + {visibleServiceEdges.map((edge) => { + const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId] + const to = servicePosById[edge.toId] + if (!from || !to) return null + const hop: MatchedNetflowHop = { + bytes: edge.bytes, + bps: edge.bps, + bpsFwd: edge.bpsFwd, + bpsRev: edge.bpsRev, + } + const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16) + const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId + return ( + + + {showAnimDots && hopHasRate(hop) && ( + + + + )} + {hopHasRate(hop) && ( + { + ev.stopPropagation() + const svc = visibleMapServices.find((s) => s.id === edge.toId) + if (svc) selectService(svc) + }} + /> + )} + + ) + })} + {/* ── Server nodes ── */} {nodes.map(n => ( { + const pos = servicePosById[svc.id] + if (!pos) return null + return ( + { + if (suppressClickRef.current) { suppressClickRef.current = false; return } + selectService(svc) + }} + /> + ) + })} + {/* ── Hover tooltip ── */} {hoveredNode && !isDragging && ( @@ -1898,7 +2110,7 @@ export default function NetworkMapPage() { {/* ── Legend (viewport-fixed) ── */} - ЛЕГЕНДА @@ -1927,12 +2139,17 @@ export default function NetworkMapPage() { ))} - + + + Сервис + - + + WAN АПЛИНКИ {WAN_COLORS.slice(0, 2).map((c, i) => ( - + WAN{i + 1} @@ -1988,6 +2205,7 @@ export default function NetworkMapPage() { satPos={effectiveSatPos} wanJhEdges={visibleWanJhEdges} homeRouters={homeRouters} + servicePos={servicePosById} onClose={() => setShowMinimap(false)} onPan={(x, y) => setPan({ x, y })} /> @@ -2016,7 +2234,7 @@ export default function NetworkMapPage() { {/* ── Side panel (узел или выбранное GRE-ребро) ── */} - {(selectedGreEdge || selected) && ( + {(selectedGreEdge || selected || selectedService) && (
{selectedGreEdge ? ( <> @@ -2222,6 +2440,62 @@ export default function NetworkMapPage() { })()}
+ ) : selectedService ? ( + <> +
+
+ +
+
+

{selectedService.label}

+

+ Конечный сервис · {selectedService.category} +

+
+ +
+
+
+
+ Доля окна + {serviceSharePct(selectedService.share)} +
+
+ Скорость + + {formatNetflowRate({ + bytes: selectedService.bytes, + bps: selectedService.bps, + bpsFwd: selectedService.bps, + bpsRev: 0, + })} + +
+
+
+

С узлов

+
+ {visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => { + const src = mapServers.find((s) => s.id === e.fromId) + return ( +
+ {src?.name ?? e.fromId} + + {formatNetflowRate({ bytes: e.bytes, bps: e.bps, bpsFwd: e.bpsFwd, bpsRev: e.bpsRev })} + +
+ ) + })} +
+
+
+ ) : selected ? ( <>
diff --git a/backend/src/services/traffic-flow-brands.test.ts b/backend/src/services/traffic-flow-brands.test.ts index 646617b..cf5c4b3 100644 --- a/backend/src/services/traffic-flow-brands.test.ts +++ b/backend/src/services/traffic-flow-brands.test.ts @@ -4,6 +4,8 @@ import { countryFromHolder, lookupBrand, OTHER_SERVICE, + isNamedInternetService, + mapServiceNodeId, resolveRipeCountry, } from "./traffic-flow-brands.js" @@ -21,9 +23,17 @@ assert.equal(brandByAsn(15169)?.category, "Веб") assert.equal(lookupBrand("208.65.153.1", 0)?.service, "YouTube") assert.equal(brandByAsn(32590)?.service, "Steam") assert.equal(brandByAsn(32590)?.category, "Игры") +assert.equal(brandByAsn(16509)?.service, "AWS") +assert.equal(brandByAsn(57976)?.service, "Blizzard") assert.equal(brandByAsn(401115)?.service, "ChatGPT") assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare") assert.equal(lookupBrand("203.0.113.9", 64500), null) assert.equal(OTHER_SERVICE, "Прочее") +assert.equal(isNamedInternetService("Google", "Веб"), true) +assert.equal(isNamedInternetService("Прочее", "Прочее"), false) +assert.equal(isNamedInternetService("GRE", "Туннель"), false) +assert.equal(isNamedInternetService("DNS", "DNS"), false) +assert.equal(mapServiceNodeId("AWS"), "svc:aws") +assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare") console.log("traffic-flow-brands.test.ts: ok") diff --git a/backend/src/services/traffic-flow-brands.ts b/backend/src/services/traffic-flow-brands.ts index 45b5fb7..caf0f8a 100644 --- a/backend/src/services/traffic-flow-brands.ts +++ b/backend/src/services/traffic-flow-brands.ts @@ -12,11 +12,12 @@ const ASN_BRANDS = new Map([ [209242, { service: "Cloudflare", category: "CDN" }], [54113, { service: "Fastly", category: "CDN" }], [20940, { service: "Akamai", category: "CDN" }], - [16509, { service: "Amazon", category: "CDN" }], - [14618, { service: "Amazon", category: "CDN" }], + [16509, { service: "AWS", category: "CDN" }], + [14618, { service: "AWS", category: "CDN" }], [8075, { service: "Microsoft", category: "CDN" }], [13238, { service: "Yandex", category: "CDN" }], [32590, { service: "Steam", category: "Игры" }], + [57976, { service: "Blizzard", category: "Игры" }], [2906, { service: "Netflix", category: "Видео / стриминг" }], [40027, { service: "Netflix", category: "Видео / стриминг" }], [15169, { service: "Google", category: "Веб" }], @@ -41,6 +42,7 @@ const ASN_HQ_COUNTRY = new Map([ [8075, "US"], [15169, "US"], [32590, "US"], + [57976, "US"], [2906, "US"], [40027, "US"], [36040, "US"], @@ -105,3 +107,32 @@ export function brandByCidr(ip: string): BrandHit | null { export function lookupBrand(ip: string, asn: number): BrandHit | null { return brandByCidr(ip) || brandByAsn(asn) } + +const SKIP_MAP_SERVICES = new Set([ + OTHER_SERVICE, + "GRE", + "ESP", + "WireGuard", + "DNS", + "SSH", + "BGP", +]) + +const SKIP_MAP_CATEGORIES = new Set(["Туннель", "DNS", "SSH", "BGP"]) + +/** Именованный интернет-сервис для карты (не туннель и не «Прочее»). */ +export function isNamedInternetService(service: string, category: string): boolean { + const s = service.trim() + const c = category.trim() + if (!s || SKIP_MAP_SERVICES.has(s) || SKIP_MAP_CATEGORIES.has(c)) return false + return true +} + +export function mapServiceNodeId(label: string): string { + const slug = label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + return `svc:${slug || "unknown"}` +} diff --git a/backend/src/services/traffic-flow-map-hops.test.ts b/backend/src/services/traffic-flow-map-hops.test.ts index 0116536..22d5343 100644 --- a/backend/src/services/traffic-flow-map-hops.test.ts +++ b/backend/src/services/traffic-flow-map-hops.test.ts @@ -11,6 +11,7 @@ import { disableRipeEnqueueForTests, disableRipePersistForTests, resetRipeCacheForTests, + seedRipeCacheForTests, } from "./traffic-flow-ripe.js" disableCatalogFetchForTests() @@ -154,4 +155,119 @@ try { resetFlowCatalogForTests() } +console.log("traffic-flow-map-hops.test.ts: hops ok") + +function googleRipe() { + seedRipeCacheForTests({ + prefix: "8.8.8.0/24", + asn: 15169, + country: "US", + lat: 37.4, + lng: -122.1, + holder: "GOOGLE", + ok: true, + fetchedAt: Date.now(), + }) +} + +function payloadFlow(dst: string, bytes: number) { + return { + src: "10.100.1.17", + dst, + proto: 6, + srcPort: 51234, + dstPort: 443, + bytes, + packets: Math.max(1, Math.round(bytes / 1200)), + inIface: "2", + outIface: "3", + nextHop: "198.51.100.1", + } +} + +resetFlowRingsForTests() +resetIfaceCacheForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*2", name: "gre-client" }, + { ".id": "*3", name: "gre-jh-en" }, +]) +googleRipe() +ingestParsedFlowsForServerForTests(7, [ + payloadFlow("8.8.8.8", 600), + payloadFlow("203.0.113.50", 9400), +]) +try { + const six = buildFlowMapHops({ minutes: 5 }) + assert.equal(six.totalBytes, 10_000) + const google = six.services?.find((s) => s.id === "svc:google") + assert.ok(google, "Google ≥ 5%") + assert.ok(google.share >= 0.05) + assert.ok(six.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9")) +} finally { + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() +} + +resetFlowRingsForTests() +resetIfaceCacheForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*2", name: "gre-client" }, + { ".id": "*3", name: "gre-jh-en" }, +]) +googleRipe() +ingestParsedFlowsForServerForTests(7, [ + payloadFlow("8.8.8.8", 400), + payloadFlow("203.0.113.50", 9600), +]) +try { + const four = buildFlowMapHops({ minutes: 5 }) + assert.equal(four.totalBytes, 10_000) + assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden") +} finally { + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() +} + +resetFlowRingsForTests() +resetIfaceCacheForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*2", name: "gre-client" }, + { ".id": "*3", name: "gre-jh-en" }, +]) +ingestParsedFlowsForServerForTests(7, [ + { + src: "203.0.113.10", + dst: "198.51.100.1", + proto: 47, + srcPort: 0, + dstPort: 0, + bytes: 9_000, + packets: 90, + inIface: "3", + outIface: "3", + }, + payloadFlow("203.0.113.50", 1000), +]) +try { + const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false }) + assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service") +} finally { + seedFlowTopologyForTests(null) + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() + resetFlowCatalogForTests() +} + console.log("traffic-flow-map-hops.test.ts: ok") diff --git a/backend/src/services/traffic-flow-map-hops.ts b/backend/src/services/traffic-flow-map-hops.ts index 0afc1a0..6b47083 100644 --- a/backend/src/services/traffic-flow-map-hops.ts +++ b/backend/src/services/traffic-flow-map-hops.ts @@ -1,14 +1,22 @@ import { eq } from "drizzle-orm" -import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow" +import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow" import { db } from "../db/index.js" import { servers, userInterfaceBindings } from "../db/schema.js" import { flowRowMatchesFilter } from "./traffic-flow-apps.js" +import { + isNamedInternetService, + mapServiceNodeId, +} from "./traffic-flow-brands.js" +import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js" import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js" import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js" import { resolveIfaceName } from "./traffic-flow-ifaces.js" import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js" +import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js" import { loadFlowTopology, resolveEn } from "./traffic-flow-topology.js" +export const MAP_SERVICE_SHARE_THRESHOLD = 0.05 + export interface FlowMapHopsQuery { minutes: number serverId?: number @@ -114,6 +122,12 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto { const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched const hops = new Map() + const svcTotals = new Map() + const svcEdges = new Map() + const dsts = new Set() + let totalBytes = 0 + + refreshFlowCatalogInBackground() for (const r of working) { const inRes = resolveIfaceName(r.serverId, r.inIface) @@ -199,8 +213,62 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto { }, r.bytes, "fwd") } } + + totalBytes += r.bytes + dsts.add(r.dst) + const ripe = lookupRipeCached(r.dst) + const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe) + if (isNamedInternetService(classified.service, classified.category)) { + const toId = mapServiceNodeId(classified.service) + const prevSvc = svcTotals.get(toId) + if (prevSvc) prevSvc.bytes += r.bytes + else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: r.bytes }) + + const svcEn = enOut ?? enIn + const svcFromId = svcEn ? String(svcEn.id) : fromId + const edgeKey = `${svcFromId}|${toId}` + const prevEdge = svcEdges.get(edgeKey) + if (prevEdge) { + prevEdge.bytes += r.bytes + prevEdge.bytesFwd += r.bytes + } else { + svcEdges.set(edgeKey, { + fromId: svcFromId, + toId, + bytes: r.bytes, + bytesFwd: r.bytes, + bytesRev: 0, + }) + } + } } + enqueueRipeMisses(dsts) + + const services: FlowMapService[] = [...svcTotals.entries()] + .map(([id, s]) => ({ + id, + label: s.label, + category: s.category, + bytes: s.bytes, + bps: (s.bytes * 8) / windowSec, + share: totalBytes > 0 ? s.bytes / totalBytes : 0, + })) + .filter((s) => s.share >= MAP_SERVICE_SHARE_THRESHOLD) + .sort((a, b) => b.bytes - a.bytes) + const keepSvc = new Set(services.map((s) => s.id)) + const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()] + .filter((e) => keepSvc.has(e.toId)) + .map((e) => ({ + fromId: e.fromId, + toId: e.toId, + bytes: e.bytes, + bps: (e.bytes * 8) / windowSec, + bpsFwd: (e.bytesFwd * 8) / windowSec, + bpsRev: (e.bytesRev * 8) / windowSec, + })) + .sort((a, b) => b.bytes - a.bytes) + const listener = getFlowListenerState() return { hops: [...hops.values()] @@ -209,6 +277,9 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto { live: listener.bound, rangeMinutes: q.minutes, windowSec, + totalBytes, + services, + serviceEdges, dedupApplied: wantDedup, excludeMeshApplied: excludeMesh, excludeOverlayApplied: excludeOverlay, diff --git a/components/network-map/service-brand-icon.tsx b/components/network-map/service-brand-icon.tsx new file mode 100644 index 0000000..c8bffaa --- /dev/null +++ b/components/network-map/service-brand-icon.tsx @@ -0,0 +1,131 @@ +"use client" + +import type { ReactNode } from "react" + +function slug(label: string): string { + return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") +} + +function GenericCloud({ size }: { size: number }) { + return ( + + + + ) +} + +function BrandSvg({ children, size }: { children: ReactNode; size: number }) { + return ( + + {children} + + ) +} + +export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: number }) { + switch (slug(label)) { + case "cloudflare": + return ( + + + + ) + case "google": + return ( + + + + + + + ) + case "aws": + case "amazon": + return ( + + + + + ) + case "steam": + return ( + + + + + + + ) + case "blizzard": + return ( + + + + + ) + case "youtube": + return ( + + + + + ) + case "netflix": + return ( + + + + + ) + case "microsoft": + return ( + + + + + + + ) + case "meta": + return ( + + + + ) + case "telegram": + return ( + + + + + ) + case "discord": + return ( + + + + + + ) + case "twitch": + return ( + + + + + ) + case "tiktok": + return ( + + + + + ) + default: + return + } +} diff --git a/lib/network-map-layout.ts b/lib/network-map-layout.ts index 2858a93..4ac39b5 100644 --- a/lib/network-map-layout.ts +++ b/lib/network-map-layout.ts @@ -35,9 +35,10 @@ export function greTunnelProbe(t: GreTunnel): TunnelProbe { } } -const W = 1060 +const W = 1240 const H = 580 const MARGIN = 72 +const SERVICE_COL_W = 150 /** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */ export const NETWORK_MAP_PIPELINE_Y = 300 @@ -68,7 +69,9 @@ function layerOfServer(s: Server): number | null { * Увеличивать при изменении алгоритма раскладки спутников/узлов. * Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload). */ -export const NETWORK_MAP_LAYOUT_REVISION = 6 +export const NETWORK_MAP_W = W +export const NETWORK_MAP_H = H +export const NETWORK_MAP_LAYOUT_REVISION = 7 export interface WanJhEdge { homeId: string @@ -254,7 +257,7 @@ export function computeNetworkMapLayout( const nodePos: Record = {} const wanSatPos: Record = {} - const span = W - 2 * MARGIN + const span = W - 2 * MARGIN - SERVICE_COL_W const laneGap = Math.min(44, span * 0.04) const laneW = (span - 2 * laneGap) / 3 @@ -425,6 +428,28 @@ export function computeNetworkMapLayout( return { nodePos, wanSatPos } } +/** Колонка конечных сервисов справа от EN. */ +export function placeServiceNodes( + serviceIds: string[], + enPositions: Array<{ x: number; y: number }>, +): Record { + const out: Record = {} + if (serviceIds.length === 0) return out + const minY = MARGIN + 70 + const maxY = H - 72 + const x = W - MARGIN - SERVICE_COL_W / 2 + const enYs = enPositions.map((p) => p.y).filter((y) => Number.isFinite(y)) + const centerY = enYs.length ? enYs.reduce((a, b) => a + b, 0) / enYs.length : (minY + maxY) / 2 + const n = serviceIds.length + const gap = Math.min(96, (maxY - minY) / Math.max(1, n)) + const span = gap * (n - 1) + const start = clamp(centerY - span / 2, minY, maxY - span) + serviceIds.forEach((id, i) => { + out[id] = { x, y: n === 1 ? clamp(centerY, minY, maxY) : start + i * gap } + }) + return out +} + /** * Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы. * Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов. diff --git a/packages/contracts/src/traffic-flow.ts b/packages/contracts/src/traffic-flow.ts index 9b5ef14..8bf8d11 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -263,11 +263,32 @@ export const flowMapHopDtoSchema = z.object({ bpsRev: z.number().nonnegative(), }) +export const flowMapServiceDtoSchema = z.object({ + id: z.string(), + label: z.string(), + category: z.string(), + bytes: z.number().nonnegative(), + bps: z.number().nonnegative(), + share: z.number().min(0).max(1), +}) + +export const flowMapServiceEdgeDtoSchema = z.object({ + fromId: z.string(), + toId: z.string(), + bytes: z.number().nonnegative(), + bps: z.number().nonnegative(), + bpsFwd: z.number().nonnegative(), + bpsRev: z.number().nonnegative(), +}) + export const flowMapHopsDtoSchema = z.object({ hops: z.array(flowMapHopDtoSchema), live: z.boolean(), rangeMinutes: z.number().int().positive(), windowSec: z.number().positive(), + totalBytes: z.number().nonnegative().optional(), + services: z.array(flowMapServiceDtoSchema).optional(), + serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(), dedupApplied: z.boolean(), excludeMeshApplied: z.boolean(), excludeOverlayApplied: z.boolean(), @@ -287,4 +308,6 @@ export type FlowMonthlyDto = z.infer export type FlowPurgeDto = z.infer export type FlowMapHopKind = z.infer export type FlowMapHop = z.infer +export type FlowMapService = z.infer +export type FlowMapServiceEdge = z.infer export type FlowMapHopsDto = z.infer