From 5188b2aff27d9c772e17d1c6dee2f1e684953e1f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 7 Sep 2026 17:39:18 +0700 Subject: [PATCH] =?UTF-8?q?fix(network-map):=20=D0=BE=D0=B1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=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=B4=D0=BE=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=83=D1=82=D0=B8=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Линия от обода EN до рамки сервиса; таблица клиент, узел и сервис с подсветкой на карте. Co-authored-by: Cursor --- app/(main)/network-map/page.tsx | 227 +++++++++++++++--- .../services/traffic-flow-map-hops.test.ts | 5 + backend/src/services/traffic-flow-map-hops.ts | 91 ++++++- lib/network-map-layout.ts | 43 ++++ packages/contracts/src/traffic-flow.ts | 14 ++ 5 files changed, 339 insertions(+), 41 deletions(-) diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index ec62ad5..51e5ae1 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -16,7 +16,10 @@ import { buildGreMapEdges, buildServerResourceMap, buildWanJhEdges, + clipSegmentCircleToRect, computeNetworkMapLayout, + MAP_SERVICE_NODE_H, + MAP_SERVICE_NODE_W, NETWORK_MAP_H, NETWORK_MAP_LAYOUT_REVISION, NETWORK_MAP_PIPELINE_Y, @@ -52,7 +55,7 @@ import { matchNetflowForWan, type MatchedNetflowHop, } from "@/lib/map-netflow-hops" -import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow" +import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } 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" @@ -289,6 +292,23 @@ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [ { 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" }] }, ] +const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [ + { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 }, + { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:google", bytes: 8_000_000, bps: 3_200_000 }, + { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000 }, + { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000 }, + { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:aws", bytes: 9_000_000, bps: 3_600_000 }, +] + +function servicePathKey(p: Pick): string { + return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}` +} + +function greMatchesPath(e: GreMapEdge, h: { viaId: string; enId: string }): boolean { + const ids = new Set([e.fromServer.id, e.toServer.id]) + return ids.has(h.viaId) && ids.has(h.enId) +} + function serviceSharePct(share: number): string { return `${Math.round(share * 100)}%` } @@ -730,8 +750,8 @@ function ServiceNode({ onClick: () => void onMouseDown: (e: React.MouseEvent) => void }) { - const bw = 86 - const bh = 58 + const bw = MAP_SERVICE_NODE_W + const bh = MAP_SERVICE_NODE_H return ( void +}) { + if (paths.length === 0) { + return

+ } + return ( +
+ {paths.map((p) => { + const rowKey = servicePathKey(p) + const via = servers.find((s) => s.id === p.viaId) + const viaLabel = via?.site || p.viaName + const svc = services.find((s) => s.id === p.serviceId) + const mid = viaMode === "via" ? viaLabel : (svc?.label ?? p.serviceId) + const active = Boolean( + highlight + && highlight.viaId === p.viaId + && highlight.enId === p.enId + && highlight.serviceId === p.serviceId, + ) + return ( + + ) + })} +
+ ) +} + function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: { x: number; y: number wan: { name: string; isp: string; maxDl: number; maxUl: number } @@ -1017,6 +1090,7 @@ export default function NetworkMapPage() { const [mapHops, setMapHops] = useState([]) const [mapServices, setMapServices] = useState([]) const [mapServiceEdges, setMapServiceEdges] = useState([]) + const [mapServicePaths, setMapServicePaths] = useState([]) const [mapSharePct, setMapSharePct] = useState(5) /** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */ const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState>({}) @@ -1112,6 +1186,7 @@ export default function NetworkMapPage() { setMapHops([]) setMapServices(MOCK_MAP_SERVICES) setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) + setMapServicePaths(MOCK_MAP_SERVICE_PATHS) setMapSharePct(5) setDataError(null) }) @@ -1141,6 +1216,7 @@ export default function NetworkMapPage() { // ── Interaction ───────────────────────────────────────────────────────────── const [selected, setSelected] = useState(null) const [selectedService, setSelectedService] = useState(null) + const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null) const [selWanIdx, setSelWanIdx] = useState(null) const [hoveredId, setHoveredId] = useState(null) @@ -1189,6 +1265,7 @@ export default function NetworkMapPage() { setMapHops([]) setMapServices(MOCK_MAP_SERVICES) setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) + setMapServicePaths(MOCK_MAP_SERVICE_PATHS) setMapSharePct(5) }) return @@ -1198,6 +1275,7 @@ export default function NetworkMapPage() { setMapHops([]) setMapServices([]) setMapServiceEdges([]) + setMapServicePaths([]) }) return } @@ -1212,6 +1290,7 @@ export default function NetworkMapPage() { setMapHops(res.hops ?? []) setMapServices(res.services ?? []) setMapServiceEdges(res.serviceEdges ?? []) + setMapServicePaths(res.servicePaths ?? []) if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct) }) .catch((err: unknown) => { @@ -1519,7 +1598,13 @@ 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); setSelectedService(null) } + if (e.key === "Escape") { + setSelected(null) + setSelWanIdx(null) + setSelectedGreEdge(null) + setSelectedService(null) + setHighlightedPath(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() @@ -1610,7 +1695,13 @@ export default function NetworkMapPage() { const moved = dragRef.current?.moved ?? false dragRef.current = null setIsDragging(false) - if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) } + if (!moved) { + setSelected(null) + setSelWanIdx(null) + setSelectedGreEdge(null) + setSelectedService(null) + setHighlightedPath(null) + } } // ── Node drag start ────────────────────────────────────────────────────── @@ -1647,6 +1738,7 @@ export default function NetworkMapPage() { function selectServer(s: Server) { setSelectedGreEdge(null) setSelectedService(null) + setHighlightedPath(null) setSelected(prev => prev?.id === s.id ? null : s) setSelWanIdx(null) setHoveredId(null) @@ -1655,15 +1747,28 @@ export default function NetworkMapPage() { setSelectedGreEdge(null) setSelected(null) setSelWanIdx(null) + setHighlightedPath(null) setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc) } function selectWan(s: Server, wanIdx: number) { setSelectedGreEdge(null) setSelectedService(null) + setHighlightedPath(null) setSelected(s) setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx) } + function togglePathHighlight(p: FlowMapServicePath) { + setHighlightedPath((prev) => ( + prev + && prev.viaId === p.viaId + && prev.enId === p.enId + && prev.serviceId === p.serviceId + ? null + : { viaId: p.viaId, enId: p.enId, serviceId: p.serviceId } + )) + } + function openWanJhSpeedDetail(ev: React.MouseEvent, edge: WanJhEdge) { ev.stopPropagation() const home = mapServers.find((s) => s.id === edge.homeId) @@ -1958,13 +2063,23 @@ export default function NetworkMapPage() { setSelectedGreEdge(e) setSelected(null) setSelectedService(null) + setHighlightedPath(null) setSelWanIdx(null) } return ( - + @@ -2177,27 +2292,61 @@ export default function NetworkMapPage() { 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 fromR = "type" in from && from.type + ? TYPE_STYLE[from.type].r + : TYPE_STYLE["exit-node"].r + const clipped = clipSegmentCircleToRect( + from.x, + from.y, + fromR, + 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 pathHit = Boolean( + highlightedPath + && highlightedPath.enId === edge.fromId + && highlightedPath.serviceId === edge.toId, + ) + const pathDim = Boolean(highlightedPath) && !pathHit + const hl = pathHit || (!highlightedPath && (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} + { ev.stopPropagation() }} + onClick={(ev) => { + ev.stopPropagation() + if (svc) selectService(svc) + }} + /> {showAnimDots && hopHasRate(hop) && ( + path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} /> )} {hopHasRate(hop) && ( @@ -2207,8 +2356,8 @@ export default function NetworkMapPage() { hop={hop} onOpen={(ev) => { ev.stopPropagation() - const svc = visibleMapServices.find((s) => s.id === edge.toId) - if (svc) selectService(svc) + const hit = visibleMapServices.find((s) => s.id === edge.toId) + if (hit) selectService(hit) }} /> )} @@ -2608,25 +2757,18 @@ 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}

- )) - })()} -
+

Пути

+ p.serviceId === selectedService.id) + .slice() + .sort((a, b) => b.bps - a.bps)} + servers={mapServers} + services={mapServices} + highlight={highlightedPath} + viaMode="via" + onToggle={togglePathHighlight} + />
@@ -2855,6 +2997,23 @@ export default function NetworkMapPage() { )} + {(selected.type === "jump-host" || selected.type === "exit-node") && ( +
+

Пути

+ p.viaId === selected.id || p.enId === selected.id) + .slice() + .sort((a, b) => b.bps - a.bps)} + servers={mapServers} + services={mapServices} + highlight={highlightedPath} + viaMode="service" + onToggle={togglePathHighlight} + /> +
+ )} + {/* Resources */} {(() => { const res = srvResMap[selected.id] diff --git a/backend/src/services/traffic-flow-map-hops.test.ts b/backend/src/services/traffic-flow-map-hops.test.ts index 3cb71d6..7ebf90b 100644 --- a/backend/src/services/traffic-flow-map-hops.test.ts +++ b/backend/src/services/traffic-flow-map-hops.test.ts @@ -401,6 +401,11 @@ try { assert.equal(googleEdge.fromId, "9", "единственный EN, даже без nextHop") assert.ok(googleEdge.bps > 0, "скорость на hop EN→сервис") assert.ok(!(wanOnly.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH") + const googlePath = wanOnly.servicePaths?.find((p) => p.serviceId === "svc:google") + assert.ok(googlePath, "путь WAN Google") + assert.equal(googlePath.viaId, "7", "via = JH exporter") + assert.equal(googlePath.enId, "9", "якорь EN") + assert.ok(googlePath.bps > 0, "скорость на пути клиента") } finally { seedFlowTopologyForTests(null) resetFlowRingsForTests() diff --git a/backend/src/services/traffic-flow-map-hops.ts b/backend/src/services/traffic-flow-map-hops.ts index bdd8291..482e85b 100644 --- a/backend/src/services/traffic-flow-map-hops.ts +++ b/backend/src/services/traffic-flow-map-hops.ts @@ -1,5 +1,5 @@ import { eq } from "drizzle-orm" -import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow" +import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" import { db } from "../db/index.js" import { servers, userInterfaceBindings } from "../db/schema.js" import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js" @@ -45,9 +45,14 @@ interface HopAcc { bytesRev: number } +interface ClientAcc { + name: string + bytes: number +} + interface FromAcc { bytes: number - clients: Map + clients: Map } interface DstAcc { @@ -58,15 +63,26 @@ interface DstAcc { fromBytes: Map } +function bumpClient(clients: Map, bytes: number, client: { userId: string; name: string } | null): void { + const id = client?.userId || "—" + const name = client?.name || "—" + const prev = clients.get(id) + if (prev) { + prev.bytes += bytes + return + } + clients.set(id, { name, bytes }) +} + 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) + bumpClient(prev.clients, bytes, client) return } - const clients = new Map() - if (client) clients.set(client.userId, client.name) + const clients = new Map() + bumpClient(clients, bytes, client) acc.fromBytes.set(exporterId, { bytes, clients }) } @@ -326,6 +342,16 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo bytesRev: number clients: Map }>() + const svcPaths = new Map() for (const h of hops.values()) { if (h.kind !== "gre" || !h.toId) continue @@ -346,6 +372,17 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo return null } + function nodeName(id: string): string { + const n = Number(id) + if (Number.isFinite(n)) { + const fromDb = nameById.get(n) + if (fromDb) return fromDb + } + const en = topo.enNodes.find((node) => String(node.id) === id) + if (en?.name) return en.name + return id + } + for (const [dst, acc] of dstAcc) { const ripe = lookupRipeCached(dst) const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe) @@ -359,10 +396,14 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo if (!fromId) continue const edgeKey = `${fromId}|${toId}` const prevEdge = svcEdges.get(edgeKey) + const namedClients = new Map() + for (const [id, c] of from.clients) { + if (id !== "—") namedClients.set(id, c.name) + } if (prevEdge) { prevEdge.bytes += from.bytes prevEdge.bytesFwd += from.bytes - for (const [id, name] of from.clients) prevEdge.clients.set(id, name) + for (const [id, name] of namedClients) prevEdge.clients.set(id, name) } else { svcEdges.set(edgeKey, { fromId, @@ -370,9 +411,29 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo bytes: from.bytes, bytesFwd: from.bytes, bytesRev: 0, - clients: new Map(from.clients), + clients: namedClients, }) } + const enName = nodeName(fromId) + const viaName = nodeName(exporterId) + for (const [clientId, c] of from.clients) { + const pathKey = `${clientId}|${exporterId}|${fromId}|${toId}` + const prevPath = svcPaths.get(pathKey) + if (prevPath) { + prevPath.bytes += c.bytes + } else { + svcPaths.set(pathKey, { + clientId, + clientName: c.name, + viaId: exporterId, + viaName, + enId: fromId, + enName, + serviceId: toId, + bytes: c.bytes, + }) + } + } } } @@ -410,6 +471,21 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo }) .sort((a, b) => b.bytes - a.bytes) + const servicePaths: FlowMapServicePath[] = [...svcPaths.values()] + .filter((p) => keepSvc.has(p.serviceId)) + .map((p) => ({ + clientId: p.clientId, + clientName: p.clientName, + viaId: p.viaId, + viaName: p.viaName, + enId: p.enId, + enName: p.enName, + serviceId: p.serviceId, + bytes: p.bytes, + bps: (p.bytes * 8) / windowSec, + })) + .sort((a, b) => b.bps - a.bps) + const listener = getFlowListenerState() return { hops: [...hops.values()] @@ -421,6 +497,7 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo totalBytes, services, serviceEdges, + servicePaths, mapServiceMinSharePct: minSharePct, dedupApplied: wantDedup, excludeMeshApplied: excludeMesh, diff --git a/lib/network-map-layout.ts b/lib/network-map-layout.ts index 4ac39b5..7bc0421 100644 --- a/lib/network-map-layout.ts +++ b/lib/network-map-layout.ts @@ -40,6 +40,10 @@ const H = 580 const MARGIN = 72 const SERVICE_COL_W = 150 +/** Карточка конечного сервиса на карте (центр = позиция узла). */ +export const MAP_SERVICE_NODE_W = 86 +export const MAP_SERVICE_NODE_H = 58 + /** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */ export const NETWORK_MAP_PIPELINE_Y = 300 @@ -828,3 +832,42 @@ export function buildGreMapEdges( } return out } + +/** + * Обрезать отрезок центр круга → центр прямоугольника по ободу круга и AABB карточки. + * Пунктир EN→сервис визуально упирается в край, как GRE под кругами узлов. + */ +export function clipSegmentCircleToRect( + x1: number, + y1: number, + r: number, + x2: number, + y2: number, + hw: number, + hh: number, + pad = 1.5, +): { x1: number; y1: number; x2: number; y2: number } { + const dx = x2 - x1 + const dy = y2 - y1 + const len = Math.hypot(dx, dy) + if (len < 1e-6) return { x1, y1, x2, y2 } + const ux = dx / len + const uy = dy / len + const sx = x1 + ux * (r + pad) + const sy = y1 + uy * (r + pad) + const absDx = Math.abs(dx) + const absDy = Math.abs(dy) + const u = Math.min( + absDx < 1e-9 ? 1 : (hw + pad) / absDx, + absDy < 1e-9 ? 1 : (hh + pad) / absDy, + ) + const uu = Math.min(Math.max(u, 0), 0.48) + const ex = x2 - dx * uu + const ey = y2 - dy * uu + if ((ex - sx) * dx + (ey - sy) * dy <= 0) { + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + return { x1: mx - ux * 2, y1: my - uy * 2, x2: mx + ux * 2, y2: my + uy * 2 } + } + return { x1: sx, y1: sy, x2: ex, y2: ey } +} diff --git a/packages/contracts/src/traffic-flow.ts b/packages/contracts/src/traffic-flow.ts index b6fa8fb..c282aa3 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -289,6 +289,18 @@ export const flowMapServiceEdgeDtoSchema = z.object({ })).optional(), }) +export const flowMapServicePathDtoSchema = z.object({ + clientId: z.string(), + clientName: z.string(), + viaId: z.string(), + viaName: z.string(), + enId: z.string(), + enName: z.string(), + serviceId: z.string(), + bytes: z.number().nonnegative(), + bps: z.number().nonnegative(), +}) + export const flowMapHopsDtoSchema = z.object({ hops: z.array(flowMapHopDtoSchema), live: z.boolean(), @@ -297,6 +309,7 @@ export const flowMapHopsDtoSchema = z.object({ totalBytes: z.number().nonnegative().optional(), services: z.array(flowMapServiceDtoSchema).optional(), serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(), + servicePaths: z.array(flowMapServicePathDtoSchema).optional(), mapServiceMinSharePct: z.number().min(0).max(100).optional(), dedupApplied: z.boolean(), excludeMeshApplied: z.boolean(), @@ -319,4 +332,5 @@ export type FlowMapHopKind = z.infer export type FlowMapHop = z.infer export type FlowMapService = z.infer export type FlowMapServiceEdge = z.infer +export type FlowMapServicePath = z.infer export type FlowMapHopsDto = z.infer