Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd1fd2c9d3 |
+132
-52
@@ -293,6 +293,85 @@ function serviceSharePct(share: number): string {
|
||||
return `${Math.round(share * 100)}%`
|
||||
}
|
||||
|
||||
function exitNodeIdsFromGre(greEdges: GreMapEdge[]): string[] {
|
||||
const ids = new Set<string>()
|
||||
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<string, { x: number; y: number }>,
|
||||
): 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<string, FlowMapServiceEdge>()
|
||||
|
||||
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 (
|
||||
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
|
||||
<title>{pathTitle}</title>
|
||||
<line
|
||||
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={hopHasRate(hop) ? 2.2 : 1.3}
|
||||
strokeDasharray="5 5"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{showAnimDots && hopHasRate(hop) && (
|
||||
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||
path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
|
||||
</circle>
|
||||
)}
|
||||
{hopHasRate(hop) && (
|
||||
<NetflowRateBadge
|
||||
mx={mx}
|
||||
my={my}
|
||||
hop={hop}
|
||||
onOpen={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
if (svc) selectService(svc)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Server nodes ── */}
|
||||
{nodes.map(n => (
|
||||
<ServerNode
|
||||
@@ -2136,6 +2166,56 @@ 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 (
|
||||
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
|
||||
<title>{pathTitle}</title>
|
||||
<line
|
||||
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={hopHasRate(hop) ? 2.4 : 1.4}
|
||||
strokeDasharray="6 5"
|
||||
opacity="0.85"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{showAnimDots && hopHasRate(hop) && (
|
||||
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||
path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
|
||||
</circle>
|
||||
)}
|
||||
{hopHasRate(hop) && (
|
||||
<NetflowRateBadge
|
||||
mx={mx}
|
||||
my={my}
|
||||
hop={hop}
|
||||
onOpen={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
if (svc) selectService(svc)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Hover tooltip ── */}
|
||||
{hoveredNode && !isDragging && (
|
||||
<SvgTooltip n={hoveredNode} />
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -327,11 +327,22 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
clients: Map<string, string>
|
||||
}>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user