From 77425cca325ece9fa8dde6783fda2900db5370e3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 7 Sep 2026 16:10:39 +0700 Subject: [PATCH] =?UTF-8?q?fix(network-map):=20=D1=8F=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D1=8B?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=D0=BD=D1=83?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=BE=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пунктир от EN, а не от JH; перетаскивание узлов сервисов; иконка Google без foreignObject. Co-authored-by: Cursor --- app/(main)/network-map/page.tsx | 80 +++++++++++++---- .../services/traffic-flow-map-hops.test.ts | 46 +++++++++- backend/src/services/traffic-flow-map-hops.ts | 89 ++++++++++++++----- components/network-map/service-brand-icon.tsx | 12 +-- packages/contracts/src/traffic-flow.ts | 6 ++ 5 files changed, 186 insertions(+), 47 deletions(-) diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index e489873..039d4cb 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -282,11 +282,11 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [ ] 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 }, + { 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" }] }, + { fromId: "srv2", toId: "svc:cloudflare", 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: "svc:cloudflare", 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: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] }, ] function serviceSharePct(share: number): string { @@ -637,7 +637,9 @@ function ServiceNode({ y, isSel, isVis, + isDragged, onClick, + onMouseDown, }: { label: string share: number @@ -645,15 +647,18 @@ function ServiceNode({ y: number isSel: boolean isVis: boolean + isDragged: boolean onClick: () => void + onMouseDown: (e: React.MouseEvent) => void }) { const bw = 86 const bh = 58 return ( { e.stopPropagation(); onMouseDown(e) }} onClick={(e) => { e.stopPropagation(); onClick() }} > {`${label} · ${serviceSharePct(share)} трафика окна`} @@ -680,14 +685,9 @@ function ServiceNode({ stroke="#22d3ee" strokeWidth={isSel ? 2.2 : 1.4} /> - -
)} - > - -
-
+ + + {label} @@ -1068,6 +1068,7 @@ export default function NetworkMapPage() { // ── Node positions (overrides POS defaults) ───────────────────────────────── const [nodePositions, setNodePositions] = useState>({}) const [satPositions, setSatPositions] = useState>({}) + const [servicePositions, setServicePositions] = useState>({}) /** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */ useEffect(() => { @@ -1079,6 +1080,7 @@ export default function NetworkMapPage() { // и перекрывают новый авто-лейаут на live-данных. setNodePositions({}) setSatPositions({}) + setServicePositions({}) sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION)) } } catch { @@ -1351,13 +1353,16 @@ export default function NetworkMapPage() { }) const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n])) - const servicePosById = placeServiceNodes( + const autoServicePos = 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)), ) + const servicePosById = Object.fromEntries( + visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]), + ) // ── Refs ───────────────────────────────────────────────────────────────────── const svgRef = useRef(null) @@ -1373,10 +1378,12 @@ export default function NetworkMapPage() { type NodeDrag = | { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean } | { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean } + | { kind: "service"; svcId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean } const nodeDragRef = useRef(null) const suppressClickRef = useRef(false) const [draggedNodeId, setDraggedNodeId] = useState(null) const [draggedSatKey, setDraggedSatKey] = useState(null) // `${homeId}-${wanIdx}` + const [draggedSvcId, setDraggedSvcId] = useState(null) const zoomRef = useRef(zoom) const panRef = useRef(pan) @@ -1486,6 +1493,8 @@ export default function NetworkMapPage() { if (nd.kind === "server") { setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } })) + } else if (nd.kind === "service") { + setServicePositions(prev => ({ ...prev, [nd.svcId]: { x: nx, y: ny } })) } else { setSatPositions(prev => { const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))] @@ -1513,6 +1522,7 @@ export default function NetworkMapPage() { nodeDragRef.current = null setDraggedNodeId(null) setDraggedSatKey(null) + setDraggedSvcId(null) return true } @@ -1533,6 +1543,10 @@ export default function NetworkMapPage() { nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false } setDraggedSatKey(`${homeId}-${wanIdx}`) } + function onServiceMouseDown(e: React.MouseEvent, svcId: string, x: number, y: number) { + nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false } + setDraggedSvcId(svcId) + } // ── Visibility / search ────────────────────────────────────────────────── const sq = search.toLowerCase().trim() @@ -1748,10 +1762,10 @@ export default function NetworkMapPage() { ? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow` : "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}

- {(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0) && ( + {(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
-

С узлов

+

Выход

{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => { const src = mapServers.find((s) => s.id === e.fromId) @@ -2506,6 +2527,27 @@ export default function NetworkMapPage() { })}
+
+

Клиенты

+
+ {(() => { + const names = new Map() + for (const e of visibleServiceEdges.filter((x) => x.toId === selectedService.id)) { + if (e.clients?.length) { + for (const c of e.clients) names.set(c.id, c.name) + } else if (e.clientName) { + names.set(e.clientId ?? e.clientName, e.clientName) + } + } + if (names.size === 0) { + return

+ } + return [...names.values()].map((name) => ( +

{name}

+ )) + })()} +
+
) : selected ? ( diff --git a/backend/src/services/traffic-flow-map-hops.test.ts b/backend/src/services/traffic-flow-map-hops.test.ts index b10a58b..55d268a 100644 --- a/backend/src/services/traffic-flow-map-hops.test.ts +++ b/backend/src/services/traffic-flow-map-hops.test.ts @@ -208,7 +208,9 @@ try { 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")) + const googleEdge = six.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9") + assert.ok(googleEdge) + assert.equal(googleEdge.clientName, "Alice") } finally { resetFlowRingsForTests() resetIfaceCacheForTests() @@ -327,4 +329,46 @@ try { resetFlowCatalogForTests() } +resetFlowRingsForTests() +resetIfaceCacheForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*1", name: "SWE-VEESP" }, + { ".id": "*2", name: "gre-client" }, + { ".id": "*3", name: "gre-jh-en" }, +]) +ingestParsedFlowsForServerForTests(7, [ + payloadFlow("8.8.8.8", 500), + { + src: "173.194.151.65", + dst: "10.200.100.53", + proto: 6, + srcPort: 443, + dstPort: 57182, + bytes: 8_000, + packets: 80, + inIface: "1", + outIface: "1", + nextHop: "", + }, +]) +try { + resetFlowMapHopsCacheForTests() + const wan = buildFlowMapHops({ minutes: 5, minSharePct: 0 }) + const googleEdge = wan.serviceEdges?.find((e) => e.toId === "svc:google") + assert.ok(googleEdge, "Google с WAN JH") + assert.equal(googleEdge.fromId, "9", "якорь на EN, не на JH") + assert.ok(!(wan.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH") + const viaGre = wan.serviceEdges?.find((e) => e.toId === "svc:google") + assert.ok(viaGre?.clients?.some((c) => c.name === "Alice") || viaGre?.clientName === "Alice") +} 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 dbff831..5c196c4 100644 --- a/backend/src/services/traffic-flow-map-hops.ts +++ b/backend/src/services/traffic-flow-map-hops.ts @@ -15,7 +15,7 @@ import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js" import { pickInternetPeer } from "./traffic-flow-ip.js" import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js" import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js" -import { loadFlowTopology, resolveEn } from "./traffic-flow-topology.js" +import { loadFlowTopology, resolveClient, resolveEn } from "./traffic-flow-topology.js" export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5 export const MAP_SERVICE_NODE_CAP = 20 @@ -45,12 +45,29 @@ interface HopAcc { bytesRev: number } +interface FromAcc { + bytes: number + clients: Map +} + interface DstAcc { bytes: number proto: number dstPort: number srcPort: number - fromBytes: Map + fromBytes: Map +} + +function bumpFrom(acc: DstAcc, exporterId: string, bytes: number, client: { userId: string; name: string } | null): void { + const prev = acc.fromBytes.get(exporterId) + if (prev) { + prev.bytes += bytes + if (client) prev.clients.set(client.userId, client.name) + return + } + const clients = new Map() + if (client) clients.set(client.userId, client.name) + acc.fromBytes.set(exporterId, { bytes, clients }) } let hopsCache: { key: string; at: number; dto: FlowMapHopsDto } | null = null @@ -190,6 +207,8 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched const hops = new Map() const dstAcc = new Map() + const jhToEn = new Map() + const enIds = new Set(topo.enNodes.map((n) => n.id)) let totalBytes = 0 for (const r of working) { @@ -241,6 +260,7 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo const en = (enOut && enOut.id !== r.serverId ? enOut : null) ?? (enIn && enIn.id !== r.serverId ? enIn : null) if (en) { + jhToEn.set(r.serverId, en.id) const toId = String(en.id) const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev" const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined) @@ -278,25 +298,42 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo } totalBytes += r.bytes - const svcFromId = String((enOut ?? enIn)?.id ?? r.serverId) const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort) + const client = resolveClient(topo, r.serverId, inName) const prevDst = dstAcc.get(peer) if (prevDst) { prevDst.bytes += r.bytes - prevDst.fromBytes.set(svcFromId, (prevDst.fromBytes.get(svcFromId) ?? 0) + r.bytes) + bumpFrom(prevDst, String(r.serverId), r.bytes, client) } else { - dstAcc.set(peer, { + const acc: DstAcc = { bytes: r.bytes, proto: r.proto, dstPort: r.dstPort, srcPort: r.srcPort, - fromBytes: new Map([[svcFromId, r.bytes]]), - }) + fromBytes: new Map(), + } + bumpFrom(acc, String(r.serverId), r.bytes, client) + dstAcc.set(peer, acc) } } const svcTotals = new Map() - const svcEdges = new Map() + const svcEdges = new Map + }>() + + 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) + return null + } for (const [dst, acc] of dstAcc) { const ripe = lookupRipeCached(dst) @@ -306,19 +343,23 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo const prevSvc = svcTotals.get(toId) if (prevSvc) prevSvc.bytes += acc.bytes else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: acc.bytes }) - for (const [fromId, bytes] of acc.fromBytes) { + for (const [exporterId, from] of acc.fromBytes) { + const fromId = anchorEnId(exporterId) + if (!fromId) continue const edgeKey = `${fromId}|${toId}` const prevEdge = svcEdges.get(edgeKey) if (prevEdge) { - prevEdge.bytes += bytes - prevEdge.bytesFwd += bytes + prevEdge.bytes += from.bytes + prevEdge.bytesFwd += from.bytes + for (const [id, name] of from.clients) prevEdge.clients.set(id, name) } else { svcEdges.set(edgeKey, { fromId, toId, - bytes, - bytesFwd: bytes, + bytes: from.bytes, + bytesFwd: from.bytes, bytesRev: 0, + clients: new Map(from.clients), }) } } @@ -342,14 +383,20 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo 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, - })) + .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 listener = getFlowListenerState() diff --git a/components/network-map/service-brand-icon.tsx b/components/network-map/service-brand-icon.tsx index c8bffaa..e9b73e5 100644 --- a/components/network-map/service-brand-icon.tsx +++ b/components/network-map/service-brand-icon.tsx @@ -36,12 +36,12 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n ) case "google": return ( - - - - - - + + + + + + ) case "aws": case "amazon": diff --git a/packages/contracts/src/traffic-flow.ts b/packages/contracts/src/traffic-flow.ts index ae947a6..b6fa8fb 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -281,6 +281,12 @@ export const flowMapServiceEdgeDtoSchema = z.object({ bps: z.number().nonnegative(), bpsFwd: z.number().nonnegative(), bpsRev: z.number().nonnegative(), + clientId: z.string().optional(), + clientName: z.string().optional(), + clients: z.array(z.object({ + id: z.string(), + name: z.string(), + })).optional(), }) export const flowMapHopsDtoSchema = z.object({