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({