From 0fdd2fc1e1bace004cad340a73678ee3c312fea3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 12 Sep 2026 12:47:30 +0700 Subject: [PATCH] =?UTF-8?q?feat(traffic-flow):=20=D1=80=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=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 - Контракт: countryServiceGroups (группа сервисов страны) в flowMapHopsDto, тип FlowMapCountryServiceGroup. - Бэкенд: вложенная агрегация brand×ISO в том же проходе hops; рёбра страна→сервис, пути с реальным EN; cap 8 / min 4, доля от байтов страны. - Хелпер mapCountryServiceNodeId: id вида cc:us|svc:google, сервисы разных стран не смешиваются. - Карта сети: клик по стране сдвигает колонку стран и раскрывает столбец её сервисов; остальные страны приглушены; панели страны и вложенного сервиса. - Тесты: группы US/US+NL, рёбра от страны, cap/min вложенного слоя; mock countryServiceGroups для демо-режима. --- app/(main)/network-map/page.tsx | 315 +++++++++++++++++- .../src/services/traffic-flow-brands.test.ts | 3 + backend/src/services/traffic-flow-brands.ts | 5 + .../services/traffic-flow-map-hops.test.ts | 131 ++++++++ backend/src/services/traffic-flow-map-hops.ts | 86 ++++- lib/network-map-layout.ts | 21 ++ packages/contracts/src/traffic-flow.ts | 10 + 7 files changed, 550 insertions(+), 21 deletions(-) diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 0fe617b..8ba74e7 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -27,7 +27,9 @@ import { findServerByGreRemote, greSourceWanIndexOnMap, greTunnelProbe, + placeCountryServiceNodes, placeServiceNodes, + SERVICE_COL_W, type GreMapEdge, type WanJhEdge, } from "@/lib/network-map-layout" @@ -55,7 +57,7 @@ import { matchNetflowForWan, type MatchedNetflowHop, } from "@/lib/map-netflow-hops" -import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" +import type { FlowMapCountryServiceGroup, 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" @@ -324,6 +326,29 @@ const MOCK_MAP_COUNTRY_PATHS: FlowMapServicePath[] = [ { 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 }, ] +/** Доли — от байтов страны cc:us (22M из моков выше). */ +const MOCK_COUNTRY_SERVICE_GROUPS: FlowMapCountryServiceGroup[] = [ + { + countryId: "cc:us", + services: [ + { id: "cc:us|svc:google", label: "Google", category: "Веб", bytes: 12_000_000, bps: 4_800_000, share: 12 / 22 }, + { id: "cc:us|svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 7_000_000, bps: 2_800_000, share: 7 / 22 }, + { id: "cc:us|svc:aws", label: "AWS", category: "CDN", bytes: 3_000_000, bps: 1_200_000, share: 3 / 22 }, + ], + edges: [ + { fromId: "cc:us", toId: "cc:us|svc:google", bytes: 12_000_000, bps: 4_800_000, bpsFwd: 9_000_000, bpsRev: 3_000_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] }, + { fromId: "cc:us", toId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000, bpsFwd: 5_250_000, bpsRev: 1_750_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] }, + { fromId: "cc:us", toId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000, bpsFwd: 2_250_000, bpsRev: 750_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] }, + ], + paths: [ + { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:google", bytes: 8_000_000, bps: 3_200_000 }, + { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:google", bytes: 4_000_000, bps: 1_600_000 }, + { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000 }, + { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000 }, + ], + }, +] + const DEST_MODE_KEY = "mm-network-map-dest-mode" type DestMode = "services" | "countries" @@ -782,6 +807,8 @@ function ServiceNode({ isDragged, destMode, iso, + dim, + shareLabel, onClick, onMouseDown, }: { @@ -794,6 +821,10 @@ function ServiceNode({ isDragged: boolean destMode: DestMode iso?: string + /** Приглушение узла при раскрытии другой страны (остаётся на холсте). */ + dim?: boolean + /** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */ + shareLabel?: string onClick: () => void onMouseDown: (e: React.MouseEvent) => void }) { @@ -804,11 +835,11 @@ function ServiceNode({ { e.stopPropagation(); onMouseDown(e) }} onClick={(e) => { e.stopPropagation(); onClick() }} > - {`${label} · ${serviceSharePct(share)} payload окна`} + {`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`} {isSel && ( ([]) const [mapCountryEdges, setMapCountryEdges] = useState([]) const [mapCountryPaths, setMapCountryPaths] = useState([]) + const [mapCountryServiceGroups, setMapCountryServiceGroups] = useState([]) const [mapSharePct, setMapSharePct] = useState(5) const [mapNamedBytes, setMapNamedBytes] = useState(0) const [mapTotalBytes, setMapTotalBytes] = useState(0) @@ -1258,6 +1290,7 @@ export default function NetworkMapPage() { setMapCountries(MOCK_MAP_COUNTRIES) setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES) setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS) + setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS) setMapSharePct(5) setMapNamedBytes(0) setMapTotalBytes(0) @@ -1290,6 +1323,8 @@ export default function NetworkMapPage() { // ── Interaction ───────────────────────────────────────────────────────────── const [selected, setSelected] = useState(null) const [selectedService, setSelectedService] = useState(null) + /** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */ + const [expandedCountryId, setExpandedCountryId] = useState(null) const [destMode, setDestModeState] = useState("services") useEffect(() => { queueMicrotask(() => setDestModeState(readDestMode())) @@ -1297,15 +1332,32 @@ export default function NetworkMapPage() { function setDestMode(mode: DestMode) { setDestModeState(mode) setSelectedService(null) + setExpandedCountryId(null) setHighlightedPath(null) try { sessionStorage.setItem(DEST_MODE_KEY, mode) } catch { /* private mode */ } } + const expandedCountryGroup = useMemo( + () => destMode === "countries" && expandedCountryId + ? mapCountryServiceGroups.find((g) => g.countryId === expandedCountryId) ?? null + : null, + [destMode, expandedCountryId, mapCountryServiceGroups], + ) + const nestedServices = useMemo(() => expandedCountryGroup?.services ?? [], [expandedCountryGroup]) const liveSelectedService = selectedService ? ( (destMode === "countries" ? mapCountries : mapServices) - .find((s) => s.id === selectedService.id) ?? selectedService + .find((s) => s.id === selectedService.id) + ?? nestedServices.find((s) => s.id === selectedService.id) + ?? selectedService ) : null + const selectedNestedService = liveSelectedService + && nestedServices.some((s) => s.id === liveSelectedService.id) + ? liveSelectedService + : null + const expandedCountry = expandedCountryId + ? mapCountries.find((c) => c.id === expandedCountryId) ?? null + : null const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null) const [selWanIdx, setSelWanIdx] = useState(null) const [hoveredId, setHoveredId] = useState(null) @@ -1359,6 +1411,7 @@ export default function NetworkMapPage() { setMapCountries(MOCK_MAP_COUNTRIES) setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES) setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS) + setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS) setMapSharePct(5) setMapCountryLoaded(true) }) @@ -1373,6 +1426,7 @@ export default function NetworkMapPage() { setMapCountries([]) setMapCountryEdges([]) setMapCountryPaths([]) + setMapCountryServiceGroups([]) }) return } @@ -1391,6 +1445,7 @@ export default function NetworkMapPage() { setMapCountries(res.countries ?? []) setMapCountryEdges(res.countryEdges ?? []) setMapCountryPaths(res.countryPaths ?? []) + setMapCountryServiceGroups(res.countryServiceGroups ?? []) if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct) setMapNamedBytes(res.namedBytes ?? 0) setMapTotalBytes(res.totalBytes ?? 0) @@ -1627,8 +1682,24 @@ export default function NetworkMapPage() { .map((s) => nodePosById[s.id]) .filter((p): p is { x: number; y: number } => Boolean(p)), ) + // Раскрытая страна: колонка стран уходит влево, правый x занимает столбец её сервисов. + const countryColShift = expandedCountryGroup ? SERVICE_COL_W : 0 + const autoDestPos = countryColShift + ? Object.fromEntries( + Object.entries(autoServicePos).map(([id, p]) => [id, { x: p.x - countryColShift, y: p.y }]), + ) + : autoServicePos const servicePosById = Object.fromEntries( - visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]), + visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoDestPos[s.id]!]), + ) + const autoNestedPos = placeCountryServiceNodes( + nestedServices.map((s) => s.id), + expandedCountryId ? servicePosById[expandedCountryId] ?? autoDestPos[expandedCountryId] : undefined, + ) + const nestedPosById = Object.fromEntries( + nestedServices + .map((s) => [s.id, servicePositions[s.id] ?? autoNestedPos[s.id]] as const) + .filter((entry): entry is readonly [string, { x: number; y: number }] => Boolean(entry[1])), ) // ── Refs ───────────────────────────────────────────────────────────────────── @@ -1713,6 +1784,7 @@ export default function NetworkMapPage() { setSelectedGreEdge(null) setSelectedService(null) setHighlightedPath(null) + setExpandedCountryId(null) } if (e.key === "=" || e.key === "+") applyZoomCenter(1.25) if (e.key === "-") applyZoomCenter(1 / 1.25) @@ -1810,6 +1882,7 @@ export default function NetworkMapPage() { setSelectedGreEdge(null) setSelectedService(null) setHighlightedPath(null) + setExpandedCountryId(null) } } @@ -1845,12 +1918,33 @@ export default function NetworkMapPage() { // ── Side panel ──────────────────────────────────────────────────────────── useEffect(() => { - if (!selectedService) return - if (!destNodes.some((s) => s.id === selectedService.id)) { - setSelectedService(null) - setHighlightedPath(null) + if ( + expandedCountryId + && (!mapCountries.some((s) => s.id === expandedCountryId) + || !mapCountryServiceGroups.some((g) => g.countryId === expandedCountryId)) + ) { + queueMicrotask(() => setExpandedCountryId(null)) } - }, [destMode, destNodes, selectedService]) + }, [mapCountries, mapCountryServiceGroups, expandedCountryId]) + useEffect(() => { + if (!selectedService) return + const stillVisible = + destNodes.some((s) => s.id === selectedService.id) + || nestedServices.some((s) => s.id === selectedService.id) + if (!stillVisible) { + queueMicrotask(() => { + setSelectedService(null) + setHighlightedPath(null) + }) + } + }, [destMode, destNodes, nestedServices, selectedService]) + useEffect(() => { + if (!showServices) queueMicrotask(() => setExpandedCountryId(null)) + }, [showServices]) + // Сдвиг колонки стран меняет систему координат: сбрасываем drag-овчины сервисов. + useEffect(() => { + queueMicrotask(() => setServicePositions({})) + }, [expandedCountryId]) function selectServer(s: Server) { setSelectedGreEdge(null) setSelectedService(null) @@ -1865,6 +1959,10 @@ export default function NetworkMapPage() { setSelWanIdx(null) setHighlightedPath(null) setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc) + // Клик по стране в режиме «Страны» раскрывает столбец её сервисов; повторный — сворачивает. + if (destMode === "countries" && svc.id.startsWith("cc:") && !svc.id.includes("|")) { + setExpandedCountryId((prev) => (prev === svc.id ? null : svc.id)) + } } function selectWan(s: Server, wanIdx: number) { setSelectedGreEdge(null) @@ -2413,6 +2511,7 @@ export default function NetworkMapPage() { y={pos.y} isSel={selectedService?.id === svc.id} isVis + dim={Boolean(expandedCountryGroup) && svc.id !== expandedCountryId} isDragged={draggedSvcId === svc.id} destMode={destMode} iso={svc.label} @@ -2425,6 +2524,31 @@ export default function NetworkMapPage() { ) })} + {/* ── Сервисы раскрытой страны (второй столбец справа) ── */} + {expandedCountryGroup && nestedServices.map((svc) => { + const pos = nestedPosById[svc.id] + if (!pos) return null + return ( + onServiceMouseDown(e, svc.id, pos.x, pos.y)} + onClick={() => { + if (suppressClickRef.current) { suppressClickRef.current = false; return } + selectService(svc) + }} + /> + ) + })} + {/* ── EN → destination services (поверх узлов, чтобы пунктир не прятался) ── */} {visibleServiceEdges.map((edge) => { const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId] @@ -2455,7 +2579,8 @@ export default function NetworkMapPage() { && highlightedPath.serviceId === edge.toId, ) const pathDim = Boolean(highlightedPath) && !pathHit - const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId)) + const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId || expandedCountryId === edge.toId)) + const countryDim = !hl && Boolean(expandedCountryGroup) && edge.toId !== expandedCountryId 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 || "—") @@ -2463,7 +2588,7 @@ export default function NetworkMapPage() { return ( {pathTitle} @@ -2509,6 +2634,81 @@ export default function NetworkMapPage() { ) })} + {/* ── Раскрытая страна → её сервисы ── */} + {expandedCountryGroup?.edges.map((edge) => { + const from = servicePosById[edge.fromId] + const to = nestedPosById[edge.toId] + if (!from || !to) return null + const hop: MatchedNetflowHop = { + bytes: edge.bytes, + bps: edge.bps, + bpsFwd: edge.bpsFwd, + bpsRev: edge.bpsRev, + } + const clipped = clipSegmentCircleToRect( + from.x, + from.y, + MAP_SERVICE_NODE_W / 2, + to.x, + to.y, + MAP_SERVICE_NODE_W / 2, + MAP_SERVICE_NODE_H / 2, + ) + const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16) + const hl = selectedService?.id === edge.toId + const svc = nestedServices.find((s) => s.id === edge.toId) + const country = mapCountries.find((s) => s.id === edge.fromId) + const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—") + const pathTitle = `${clientLabel} → ${destDisplayLabel(country, "countries", edge.fromId)} → ${edge.toId.split("|")[1] ?? edge.toId}` + return ( + + {pathTitle} + + { ev.stopPropagation() }} + onClick={(ev) => { + ev.stopPropagation() + if (svc) selectService(svc) + }} + /> + {showAnimDots && hopHasRate(hop) && ( + + + + )} + {hopHasRate(hop) && ( + { + ev.stopPropagation() + const hit = nestedServices.find((s) => s.id === edge.toId) + if (hit) selectService(hit) + }} + /> + )} + + ) + })} + {/* ── Hover tooltip ── */} {hoveredNode && !isDragging && ( @@ -2613,7 +2813,7 @@ export default function NetworkMapPage() { satPos={effectiveSatPos} wanJhEdges={visibleWanJhEdges} homeRouters={homeRouters} - servicePos={servicePosById} + servicePos={{ ...servicePosById, ...nestedPosById }} onClose={() => setShowMinimap(false)} onPan={(x, y) => setPan({ x, y })} /> @@ -2848,6 +3048,71 @@ export default function NetworkMapPage() { })()} + ) : selectedNestedService ? ( + <> +
+
+ +
+
+

+ {selectedNestedService.label} +

+

+ {`Сервис в ${destDisplayLabel(expandedCountry ?? undefined, "countries")} · ${selectedNestedService.category}`} +

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

Выход

+ p.serviceId === selectedNestedService.id) + .slice() + .sort((a, b) => b.bps - a.bps)} + servers={mapServers} + services={nestedServices} + highlight={highlightedPath} + viaMode="via" + destMode="services" + onToggle={togglePathHighlight} + /> +
+
+ ) : liveSelectedService ? ( <>
@@ -2924,6 +3189,30 @@ export default function NetworkMapPage() {
+ {expandedCountryGroup && liveSelectedService.id === expandedCountryGroup.countryId && ( +
+

Сервисы в стране

+
+ {expandedCountryGroup.services.map((svc) => ( + + ))} +
+
+ )}

Выход

diff --git a/backend/src/services/traffic-flow-brands.test.ts b/backend/src/services/traffic-flow-brands.test.ts index a12e7b4..e690104 100644 --- a/backend/src/services/traffic-flow-brands.test.ts +++ b/backend/src/services/traffic-flow-brands.test.ts @@ -9,6 +9,7 @@ import { isNamedInternetService, mapServiceNodeId, mapCountryNodeId, + mapCountryServiceNodeId, resolveFlowBrand, resolveRipeCountry, } from "./traffic-flow-brands.js" @@ -50,6 +51,8 @@ assert.equal(mapCountryNodeId("nl"), "cc:nl") assert.equal(mapCountryNodeId(""), "cc:other") assert.equal(mapCountryNodeId("Прочее"), "cc:other") assert.equal(mapCountryNodeId("EU"), "cc:other") +assert.equal(mapCountryServiceNodeId("cc:us", "svc:google"), "cc:us|svc:google") +assert.equal(mapCountryServiceNodeId("cc:other", "svc:other"), "cc:other|svc:other") assert.equal(brandByAsn(714)?.service, "Apple") assert.equal(brandByAsn(714)?.category, "CDN") diff --git a/backend/src/services/traffic-flow-brands.ts b/backend/src/services/traffic-flow-brands.ts index 1c30532..f949285 100644 --- a/backend/src/services/traffic-flow-brands.ts +++ b/backend/src/services/traffic-flow-brands.ts @@ -371,3 +371,8 @@ export function mapCountryNodeId(code: string): string { if (!iso) return "cc:other" return `cc:${iso.toLowerCase()}` } + +/** id сервиса внутри страны: `cc:us|svc:google` — Google в US и NL не смешиваются в одном payload. */ +export function mapCountryServiceNodeId(countryId: string, serviceId: string): string { + return `${countryId}|${serviceId}` +} diff --git a/backend/src/services/traffic-flow-map-hops.test.ts b/backend/src/services/traffic-flow-map-hops.test.ts index f2a94f7..0b1feab 100644 --- a/backend/src/services/traffic-flow-map-hops.test.ts +++ b/backend/src/services/traffic-flow-map-hops.test.ts @@ -6,6 +6,8 @@ import { } from "./traffic-flow-ingest.js" import { buildFlowMapHops, + MAP_COUNTRY_SERVICE_MIN_NODES, + MAP_COUNTRY_SERVICE_NODE_CAP, MAP_SERVICE_MIN_NODES, MAP_SERVICE_NODE_CAP, pickMapServices, @@ -866,4 +868,133 @@ try { resetFlowCatalogForTests() } +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 { + resetFlowMapHopsCacheForTests() + const nested = await buildFlowMapHops({ minutes: 5, minSharePct: 5 }) + const usGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:us") + assert.ok(usGroup, "группа сервисов cc:us") + const nestedGoogle = usGroup.services.find((s) => s.id === "cc:us|svc:google") + assert.ok(nestedGoogle, "cc:us|svc:google в группе") + assert.equal(nestedGoogle.label, "Google") + assert.ok(Math.abs(nestedGoogle.share - 1) < 0.01, "доля от байтов страны (600/600), не окна") + const nestedEdge = usGroup.edges.find((e) => e.toId === "cc:us|svc:google") + assert.ok(nestedEdge) + assert.equal(nestedEdge.fromId, "cc:us", "ребро вложенного слоя: страна → сервис") + assert.equal(nestedEdge.bytes, 600) + assert.ok(!usGroup.edges.some((e) => e.fromId.startsWith("svc:")), "fromId вложенных рёбер не svc:*") + const nestedPath = usGroup.paths.find((p) => p.serviceId === "cc:us|svc:google") + assert.ok(nestedPath) + assert.equal(nestedPath.enId, "9", "путь держит реальный EN для подсветки HR→JH→EN") + assert.equal(nestedPath.clientName, "Alice") + const otherGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:other") + assert.ok(otherGroup, "группа cc:other (Прочее-страна)") + assert.ok(otherGroup.services.some((s) => s.id === "cc:other|svc:other")) +} finally { + seedFlowTopologyForTests(null) + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() + 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 nestedSplit = await buildFlowMapHops({ minutes: 5, minSharePct: 5 }) + const usNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:us") + const nlNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:nl") + assert.ok(usNested && nlNested, "группы у обеих стран") + assert.ok(usNested.services.every((s) => s.id.startsWith("cc:us|")), "сервисы US только cc:us|*") + assert.ok(nlNested.services.every((s) => s.id.startsWith("cc:nl|")), "сервисы NL только cc:nl|*") + assert.ok(usNested.services.some((s) => s.id === "cc:us|svc:google"), "Google в US") + assert.ok(nlNested.services.some((s) => s.id === "cc:nl|svc:cloudflare"), "Cloudflare в NL") + assert.ok(!nlNested.services.some((s) => s.id === "cc:us|svc:google"), "Google US не утек в NL") + assert.ok(usNested.edges.every((e) => e.fromId === "cc:us")) + assert.ok(nlNested.edges.every((e) => e.fromId === "cc:nl")) +} finally { + seedFlowTopologyForTests(null) + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() + resetFlowCatalogForTests() +} + +resetFlowRingsForTests() +resetIfaceCacheForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*2", name: "gre-client" }, + { ".id": "*3", name: "gre-jh-en" }, +]) +googleRipe() +for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder) +ingestParsedFlowsForServerForTests(7, [ + payloadFlow("8.8.8.8", 5000), + ...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)), +]) +try { + resetFlowMapHopsCacheForTests() + const nestedTop = await buildFlowMapHops({ minutes: 5, minSharePct: 5 }) + const usTop = nestedTop.countryServiceGroups?.find((g) => g.countryId === "cc:us") + assert.ok(usTop) + assert.equal( + usTop.services.length, + MAP_COUNTRY_SERVICE_MIN_NODES, + "мелкий хвост держится минимумом узлов внутри страны", + ) + assert.ok(usTop.services.some((s) => s.id === "cc:us|svc:google")) + resetFlowMapHopsCacheForTests() + const nestedAll = await buildFlowMapHops({ minutes: 5, minSharePct: 0 }) + const usAll = nestedAll.countryServiceGroups?.find((g) => g.countryId === "cc:us") + assert.ok(usAll) + assert.equal(usAll.services.length, MAP_COUNTRY_SERVICE_NODE_CAP, "cap вложенного слоя = 8") + assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:epic"), "ранг 9+ скрыт") + assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:riot")) +} 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 83ed277..e0d49bd 100644 --- a/backend/src/services/traffic-flow-map-hops.ts +++ b/backend/src/services/traffic-flow-map-hops.ts @@ -1,9 +1,9 @@ import { eq } from "drizzle-orm" -import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" +import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" import { db } from "../db/index.js" import { userInterfaceBindings } from "../db/schema.js" import { flowRowMatchesFilter } from "./traffic-flow-apps.js" -import { OTHER_SERVICE, isNamedInternetService, mapCountryNodeId, mapServiceNodeId, resolveRipeCountry } from "./traffic-flow-brands.js" +import { OTHER_SERVICE, isNamedInternetService, mapCountryNodeId, mapCountryServiceNodeId, 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" @@ -21,6 +21,10 @@ export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5 export const MAP_SERVICE_NODE_CAP = 20 /** Минимум узлов-брендов на карте, даже если доля ниже порога. */ export const MAP_SERVICE_MIN_NODES = 8 +/** Cap сервисов внутри раскрытой страны (база доли — байты страны, не окна). */ +export const MAP_COUNTRY_SERVICE_NODE_CAP = 8 +/** Минимум узлов-сервисов внутри страны, даже если доля ниже порога. */ +export const MAP_COUNTRY_SERVICE_MIN_NODES = 4 export const MAP_COUNTRY_CATEGORY = "Страна" const HOPS_CACHE_TTL_MS = 2000 @@ -128,12 +132,17 @@ export function clampMapServiceMinSharePct(n: unknown): number { } /** Доля от payload окна; порог ИЛИ топ-N, затем cap. */ -export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] { - if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP) +export function pickMapServices( + ranked: FlowMapService[], + minSharePct: number, + cap: number = MAP_SERVICE_NODE_CAP, + minNodes: number = MAP_SERVICE_MIN_NODES, +): FlowMapService[] { + if (minSharePct <= 0) return ranked.slice(0, cap) const minShare = minSharePct / 100 return ranked - .filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES) - .slice(0, MAP_SERVICE_NODE_CAP) + .filter((s, i) => s.share >= minShare || i < minNodes) + .slice(0, cap) } function bumpDestTotal(totals: Map, id: string, label: string, category: string, bytes: number): void { @@ -154,8 +163,10 @@ function bumpDestFrom( fromId: string, enName: string, viaName: string, + /** fromId ребра, если отличается от EN (вложенный слой: страна → сервис). */ + edgeFromId: string = fromId, ): void { - const edgeKey = `${fromId}|${toId}` + const edgeKey = `${edgeFromId}|${toId}` const prevEdge = edges.get(edgeKey) const namedClients = new Map() for (const [id, c] of from.clients) { @@ -167,7 +178,7 @@ function bumpDestFrom( for (const [id, name] of namedClients) prevEdge.clients.set(id, name) } else { edges.set(edgeKey, { - fromId, + fromId: edgeFromId, toId, bytes: from.bytes, bytesFwd: from.bytes, @@ -207,6 +218,8 @@ function finalizeDestLayer( windowSec: number, minSharePct: number, shareBase: number, + cap: number = MAP_SERVICE_NODE_CAP, + minNodes: number = MAP_SERVICE_MIN_NODES, ): { nodes: FlowMapService[]; edges: FlowMapServiceEdge[]; paths: FlowMapServicePath[] } { const nodes = pickMapServices( [...totals.entries()] @@ -220,6 +233,8 @@ function finalizeDestLayer( })) .sort((a, b) => b.bytes - a.bytes), minSharePct, + cap, + minNodes, ) const keep = new Set(nodes.map((s) => s.id)) const outEdges: FlowMapServiceEdge[] = [...edges.values()] @@ -256,6 +271,46 @@ function finalizeDestLayer( return { nodes, edges: outEdges, paths: outPaths } } +/** Группы сервисов по странам: только страны, прошедшие отбор слоя стран; доля — от байтов страны, не окна. */ +function buildCountryServiceGroups( + nestedTotals: Map, + nestedEdges: Map, + nestedPaths: Map, + countryNodes: FlowMapService[], + windowSec: number, + minSharePct: number, +): FlowMapCountryServiceGroup[] { + const groups: FlowMapCountryServiceGroup[] = [] + for (const country of countryNodes) { + const prefix = `${country.id}|` + const totals = new Map() + const edges = new Map() + const paths = new Map() + for (const [id, t] of nestedTotals) { + if (id.startsWith(prefix)) totals.set(id, t) + } + if (totals.size === 0) continue + for (const [key, e] of nestedEdges) { + if (e.toId.startsWith(prefix)) edges.set(key, e) + } + for (const [key, p] of nestedPaths) { + if (p.serviceId.startsWith(prefix)) paths.set(key, p) + } + const out = finalizeDestLayer( + totals, + edges, + paths, + windowSec, + minSharePct, + country.bytes, + MAP_COUNTRY_SERVICE_NODE_CAP, + MAP_COUNTRY_SERVICE_MIN_NODES, + ) + groups.push({ countryId: country.id, services: out.nodes, edges: out.edges, paths: out.paths }) + } + return groups +} + 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 } @@ -533,6 +588,9 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number const ccTotals = new Map() const ccEdges = new Map() const ccPaths = new Map() + const nestedTotals = new Map() + const nestedEdges = new Map() + const nestedPaths = new Map() for (const h of hops.values()) { if (h.kind !== "gre" || !h.toId) continue @@ -573,6 +631,8 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number bumpDestTotal(svcTotals, svcId, classified.service, classified.category, acc.bytes) const country = countryDestFromRipe(ripe, dst) bumpDestTotal(ccTotals, country.id, country.label, country.category, acc.bytes) + const nestedId = mapCountryServiceNodeId(country.id, svcId) + bumpDestTotal(nestedTotals, nestedId, classified.service, classified.category, acc.bytes) for (const [exporterId, from] of acc.fromBytes) { const fromId = anchorEnId(exporterId) if (!fromId) continue @@ -580,6 +640,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number const viaName = nodeName(exporterId) bumpDestFrom(svcEdges, svcPaths, svcId, from, exporterId, fromId, enName, viaName) bumpDestFrom(ccEdges, ccPaths, country.id, from, exporterId, fromId, enName, viaName) + bumpDestFrom(nestedEdges, nestedPaths, nestedId, from, exporterId, fromId, enName, viaName, country.id) } } @@ -590,6 +651,14 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number const shareBase = totalBytes > 0 ? totalBytes : namedBytes const servicesOut = finalizeDestLayer(svcTotals, svcEdges, svcPaths, windowSec, minSharePct, shareBase) const countriesOut = finalizeDestLayer(ccTotals, ccEdges, ccPaths, windowSec, minSharePct, shareBase) + const countryServiceGroups = buildCountryServiceGroups( + nestedTotals, + nestedEdges, + nestedPaths, + countriesOut.nodes, + windowSec, + minSharePct, + ) const listener = getFlowListenerState() const geo = geoipReadersStatus() @@ -611,6 +680,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number countries: countriesOut.nodes, countryEdges: countriesOut.edges, countryPaths: countriesOut.paths, + countryServiceGroups, mapServiceMinSharePct: minSharePct, dedupApplied: wantDedup, excludeMeshApplied: excludeMesh, diff --git a/lib/network-map-layout.ts b/lib/network-map-layout.ts index 7bc0421..a1765ea 100644 --- a/lib/network-map-layout.ts +++ b/lib/network-map-layout.ts @@ -39,6 +39,7 @@ const W = 1240 const H = 580 const MARGIN = 72 const SERVICE_COL_W = 150 +export { SERVICE_COL_W } /** Карточка конечного сервиса на карте (центр = позиция узла). */ export const MAP_SERVICE_NODE_W = 86 @@ -454,6 +455,26 @@ export function placeServiceNodes( return out } +/** Столбец сервисов раскрытой страны: тот же правый x, по вертикали вокруг Y страны. */ +export function placeCountryServiceNodes( + serviceIds: string[], + parentPos: { x: number; y: number } | undefined, +): Record { + const out: Record = {} + if (serviceIds.length === 0 || !parentPos) return out + const minY = MARGIN + 70 + const maxY = H - 72 + const x = W - MARGIN - SERVICE_COL_W / 2 + const n = serviceIds.length + const gap = Math.min(80, (maxY - minY) / Math.max(1, n)) + const span = gap * (n - 1) + const start = clamp(parentPos.y - span / 2, minY, maxY - span) + serviceIds.forEach((id, i) => { + out[id] = { x, y: n === 1 ? clamp(parentPos.y, 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 ddfe47e..26339b5 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -316,6 +316,14 @@ export const flowMapServicePathDtoSchema = z.object({ bps: z.number().nonnegative(), }) +/** Сервисы внутри одной страны: id вида `cc:us|svc:google`, рёбра от `countryId`. */ +export const flowMapCountryServiceGroupDtoSchema = z.object({ + countryId: z.string(), + services: z.array(flowMapServiceDtoSchema), + edges: z.array(flowMapServiceEdgeDtoSchema), + paths: z.array(flowMapServicePathDtoSchema), +}) + export const flowMapHopsDtoSchema = z.object({ hops: z.array(flowMapHopDtoSchema), live: z.boolean(), @@ -332,6 +340,7 @@ export const flowMapHopsDtoSchema = z.object({ countries: z.array(flowMapServiceDtoSchema).optional(), countryEdges: z.array(flowMapServiceEdgeDtoSchema).optional(), countryPaths: z.array(flowMapServicePathDtoSchema).optional(), + countryServiceGroups: z.array(flowMapCountryServiceGroupDtoSchema).optional(), mapServiceMinSharePct: z.number().min(0).max(100).optional(), dedupApplied: z.boolean(), excludeMeshApplied: z.boolean(), @@ -356,4 +365,5 @@ export type FlowMapHop = z.infer export type FlowMapService = z.infer export type FlowMapServiceEdge = z.infer export type FlowMapServicePath = z.infer +export type FlowMapCountryServiceGroup = z.infer export type FlowMapHopsDto = z.infer