fix(network-map): обрезать пунктир до сервиса и показать пути клиентов
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m58s
Docker images / frontend-image (push) Successful in 3m17s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 13s

Линия от обода EN до рамки сервиса; таблица клиент, узел и сервис с подсветкой на карте.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-07 17:39:18 +07:00
co-authored by Cursor
parent cd1fd2c9d3
commit 5188b2aff2
5 changed files with 339 additions and 41 deletions
@@ -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()
+84 -7
View File
@@ -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<string, string>
clients: Map<string, ClientAcc>
}
interface DstAcc {
@@ -58,15 +63,26 @@ interface DstAcc {
fromBytes: Map<string, FromAcc>
}
function bumpClient(clients: Map<string, ClientAcc>, 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<string, string>()
if (client) clients.set(client.userId, client.name)
const clients = new Map<string, ClientAcc>()
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<string, string>
}>()
const svcPaths = new Map<string, {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}>()
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<string, string>()
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,