From cd1fd2c9d3c7dea219d7f4f3eb0f15b00251d052 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 7 Sep 2026 17:02:28 +0700 Subject: [PATCH] =?UTF-8?q?fix(network-map):=20=D1=80=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D1=83=D0=BD=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=20=D0=B8=20=D1=81=D0=BA=D0=BE=D1=80=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=BE=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Якорь hop на EN даже без nextHop; линия ARN\to Google с NetflowRateBadge. Co-authored-by: Cursor --- app/(main)/network-map/page.tsx | 184 +++++++++++++----- .../services/traffic-flow-map-hops.test.ts | 38 ++++ backend/src/services/traffic-flow-map-hops.ts | 11 ++ 3 files changed, 181 insertions(+), 52 deletions(-) diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 039d4cb..ec62ad5 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -293,6 +293,85 @@ function serviceSharePct(share: number): string { return `${Math.round(share * 100)}%` } +function exitNodeIdsFromGre(greEdges: GreMapEdge[]): string[] { + const ids = new Set() + for (const g of greEdges) { + if (g.fromServer.type === "exit-node") ids.add(g.fromServer.id) + if (g.toServer.type === "exit-node") ids.add(g.toServer.id) + } + return [...ids] +} + +function remapServiceEdgeFromId( + fromId: string, + servers: Server[], + greEdges: GreMapEdge[], + soleEnId: string | null, +): string | null { + const srv = servers.find((s) => s.id === fromId) + if (srv?.type === "exit-node") return fromId + for (const g of greEdges) { + if (g.fromServer.id === fromId && g.toServer.type === "exit-node") return g.toServer.id + if (g.toServer.id === fromId && g.fromServer.type === "exit-node") return g.fromServer.id + } + return soleEnId +} + +/** API-рёбра на EN; если hop нет — синтез от единственного EN на карте (GRE / каталог). */ +function drawableServiceEdges( + services: FlowMapService[], + edges: FlowMapServiceEdge[], + servers: Server[], + greEdges: GreMapEdge[], + nodePosById: Record, +): FlowMapServiceEdge[] { + const keep = new Set(services.map((s) => s.id)) + const greEn = exitNodeIdsFromGre(greEdges) + const catalogEn = servers.filter((s) => s.type === "exit-node").map((s) => s.id) + const enPool = greEn.length > 0 ? greEn : catalogEn + const soleEnId = enPool.length === 1 ? enPool[0]! : null + const merged = new Map() + + function bump(e: FlowMapServiceEdge) { + const key = `${e.fromId}|${e.toId}` + const prev = merged.get(key) + if (!prev) { + merged.set(key, { ...e }) + return + } + merged.set(key, { + ...prev, + bytes: prev.bytes + e.bytes, + bps: prev.bps + e.bps, + bpsFwd: prev.bpsFwd + e.bpsFwd, + bpsRev: prev.bpsRev + e.bpsRev, + }) + } + + for (const e of edges) { + if (!keep.has(e.toId)) continue + const fromId = remapServiceEdgeFromId(e.fromId, servers, greEdges, soleEnId) + if (!fromId || !nodePosById[fromId]) continue + bump({ ...e, fromId }) + } + + const covered = new Set([...merged.values()].map((e) => e.toId)) + if (soleEnId && nodePosById[soleEnId]) { + for (const svc of services) { + if (covered.has(svc.id)) continue + bump({ + fromId: soleEnId, + toId: svc.id, + bytes: svc.bytes, + bps: svc.bps, + bpsFwd: svc.bps, + bpsRev: 0, + }) + } + } + return [...merged.values()] +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function pingColor(ms: number | null) { @@ -1337,9 +1416,9 @@ export default function NetworkMapPage() { }, [homeRouters, wanJhEdges, mapHops, showNetflow]) const visibleMapServices = showServices ? mapServices : [] - const visibleServiceEdges = showServices ? mapServiceEdges.filter((e) => - visibleMapServices.some((s) => s.id === e.toId), - ) : [] + const visibleServiceEdges = showServices + ? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById) + : [] const nodes = mapServers .map((s) => ({ ...s, ...nodePosById[s.id]! })) @@ -2019,55 +2098,6 @@ export default function NetworkMapPage() { ) })} - {/* ── EN → 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 - 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}` - return ( - - {pathTitle} - - {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 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 + 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}` + return ( + + {pathTitle} + + {showAnimDots && hopHasRate(hop) && ( + + + + )} + {hopHasRate(hop) && ( + { + ev.stopPropagation() + const svc = visibleMapServices.find((s) => s.id === edge.toId) + if (svc) selectService(svc) + }} + /> + )} + + ) + })} + {/* ── Hover tooltip ── */} {hoveredNode && !isDragging && ( diff --git a/backend/src/services/traffic-flow-map-hops.test.ts b/backend/src/services/traffic-flow-map-hops.test.ts index 55d268a..3cb71d6 100644 --- a/backend/src/services/traffic-flow-map-hops.test.ts +++ b/backend/src/services/traffic-flow-map-hops.test.ts @@ -371,4 +371,42 @@ try { resetFlowCatalogForTests() } +resetFlowRingsForTests() +resetIfaceCacheForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*1", name: "SWE-VEESP" }, +]) +ingestParsedFlowsForServerForTests(7, [ + { + src: "173.194.151.65", + dst: "10.200.100.53", + proto: 6, + srcPort: 443, + dstPort: 57182, + bytes: 9_000, + packets: 80, + inIface: "1", + outIface: "1", + nextHop: "", + }, +]) +try { + resetFlowMapHopsCacheForTests() + const wanOnly = buildFlowMapHops({ minutes: 5, minSharePct: 0 }) + const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:google") + assert.ok(googleEdge, "Google WAN без GRE payload") + assert.equal(googleEdge.fromId, "9", "единственный EN, даже без nextHop") + assert.ok(googleEdge.bps > 0, "скорость на hop EN→сервис") + assert.ok(!(wanOnly.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH") +} 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 5c196c4..bdd8291 100644 --- a/backend/src/services/traffic-flow-map-hops.ts +++ b/backend/src/services/traffic-flow-map-hops.ts @@ -327,11 +327,22 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo clients: Map }>() + for (const h of hops.values()) { + if (h.kind !== "gre" || !h.toId) continue + const from = Number(h.fromId) + const to = Number(h.toId) + if (!Number.isFinite(from) || !Number.isFinite(to)) continue + if (enIds.has(to) && !enIds.has(from)) jhToEn.set(from, to) + } + + const soleEnId = topo.enNodes.length === 1 ? String(topo.enNodes[0]!.id) : null + function anchorEnId(exporterId: string): string | null { const n = Number(exporterId) if (enIds.has(n)) return exporterId const mapped = jhToEn.get(n) if (mapped != null) return String(mapped) + if (soleEnId) return soleEnId return null }