fix(network-map): рисовать пунктир и скорость до сервиса
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m41s
Docker images / frontend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m41s
Docker images / frontend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
Якорь hop на EN даже без nextHop; линия ARN\to Google с NetflowRateBadge. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+132
-52
@@ -293,6 +293,85 @@ function serviceSharePct(share: number): string {
|
|||||||
return `${Math.round(share * 100)}%`
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function pingColor(ms: number | null) {
|
function pingColor(ms: number | null) {
|
||||||
@@ -1337,9 +1416,9 @@ export default function NetworkMapPage() {
|
|||||||
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
|
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
|
||||||
|
|
||||||
const visibleMapServices = showServices ? mapServices : []
|
const visibleMapServices = showServices ? mapServices : []
|
||||||
const visibleServiceEdges = showServices ? mapServiceEdges.filter((e) =>
|
const visibleServiceEdges = showServices
|
||||||
visibleMapServices.some((s) => s.id === e.toId),
|
? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById)
|
||||||
) : []
|
: []
|
||||||
|
|
||||||
const nodes = mapServers
|
const nodes = mapServers
|
||||||
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
|
.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 ── */}
|
{/* ── Server nodes ── */}
|
||||||
{nodes.map(n => (
|
{nodes.map(n => (
|
||||||
<ServerNode
|
<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 ── */}
|
{/* ── Hover tooltip ── */}
|
||||||
{hoveredNode && !isDragging && (
|
{hoveredNode && !isDragging && (
|
||||||
<SvgTooltip n={hoveredNode} />
|
<SvgTooltip n={hoveredNode} />
|
||||||
|
|||||||
@@ -371,4 +371,42 @@ try {
|
|||||||
resetFlowCatalogForTests()
|
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")
|
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||||
|
|||||||
@@ -327,11 +327,22 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
|||||||
clients: Map<string, string>
|
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 {
|
function anchorEnId(exporterId: string): string | null {
|
||||||
const n = Number(exporterId)
|
const n = Number(exporterId)
|
||||||
if (enIds.has(n)) return exporterId
|
if (enIds.has(n)) return exporterId
|
||||||
const mapped = jhToEn.get(n)
|
const mapped = jhToEn.get(n)
|
||||||
if (mapped != null) return String(mapped)
|
if (mapped != null) return String(mapped)
|
||||||
|
if (soleEnId) return soleEnId
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user