fix(network-map): якорить сервисы на выходную ноду
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m46s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 16s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m46s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 16s
Пунктир от EN, а не от JH; перетаскивание узлов сервисов; иконка Google без foreignObject. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -282,11 +282,11 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
|
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: "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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 {
|
function serviceSharePct(share: number): string {
|
||||||
@@ -637,7 +637,9 @@ function ServiceNode({
|
|||||||
y,
|
y,
|
||||||
isSel,
|
isSel,
|
||||||
isVis,
|
isVis,
|
||||||
|
isDragged,
|
||||||
onClick,
|
onClick,
|
||||||
|
onMouseDown,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
share: number
|
share: number
|
||||||
@@ -645,15 +647,18 @@ function ServiceNode({
|
|||||||
y: number
|
y: number
|
||||||
isSel: boolean
|
isSel: boolean
|
||||||
isVis: boolean
|
isVis: boolean
|
||||||
|
isDragged: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
onMouseDown: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const bw = 86
|
const bw = 86
|
||||||
const bh = 58
|
const bh = 58
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
transform={`translate(${x},${y})`}
|
transform={`translate(${x},${y})`}
|
||||||
style={{ cursor: "pointer", transition: "opacity 0.25s" }}
|
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
||||||
opacity={isVis ? 1 : 0.08}
|
opacity={isVis ? 1 : 0.08}
|
||||||
|
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
|
||||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||||
>
|
>
|
||||||
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
|
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
|
||||||
@@ -680,14 +685,9 @@ function ServiceNode({
|
|||||||
stroke="#22d3ee"
|
stroke="#22d3ee"
|
||||||
strokeWidth={isSel ? 2.2 : 1.4}
|
strokeWidth={isSel ? 2.2 : 1.4}
|
||||||
/>
|
/>
|
||||||
<foreignObject x={-14} y={-24} width={28} height={28} style={{ overflow: "visible", pointerEvents: "none" }}>
|
<g transform="translate(-11,-24)" pointerEvents="none">
|
||||||
<div
|
<ServiceBrandIcon label={label} size={22} />
|
||||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28 }}
|
</g>
|
||||||
{...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}
|
|
||||||
>
|
|
||||||
<ServiceBrandIcon label={label} size={22} />
|
|
||||||
</div>
|
|
||||||
</foreignObject>
|
|
||||||
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
|
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
|
||||||
{label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
@@ -1068,6 +1068,7 @@ export default function NetworkMapPage() {
|
|||||||
// ── Node positions (overrides POS defaults) ─────────────────────────────────
|
// ── Node positions (overrides POS defaults) ─────────────────────────────────
|
||||||
const [nodePositions, setNodePositions] = useState<Record<string, { x: number; y: number }>>({})
|
const [nodePositions, setNodePositions] = useState<Record<string, { x: number; y: number }>>({})
|
||||||
const [satPositions, setSatPositions] = useState<Record<string, { x: number; y: number }[]>>({})
|
const [satPositions, setSatPositions] = useState<Record<string, { x: number; y: number }[]>>({})
|
||||||
|
const [servicePositions, setServicePositions] = useState<Record<string, { x: number; y: number }>>({})
|
||||||
|
|
||||||
/** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */
|
/** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1079,6 +1080,7 @@ export default function NetworkMapPage() {
|
|||||||
// и перекрывают новый авто-лейаут на live-данных.
|
// и перекрывают новый авто-лейаут на live-данных.
|
||||||
setNodePositions({})
|
setNodePositions({})
|
||||||
setSatPositions({})
|
setSatPositions({})
|
||||||
|
setServicePositions({})
|
||||||
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
|
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1351,13 +1353,16 @@ export default function NetworkMapPage() {
|
|||||||
})
|
})
|
||||||
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
|
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
|
||||||
|
|
||||||
const servicePosById = placeServiceNodes(
|
const autoServicePos = placeServiceNodes(
|
||||||
visibleMapServices.map((s) => s.id),
|
visibleMapServices.map((s) => s.id),
|
||||||
mapServers
|
mapServers
|
||||||
.filter((s) => s.type === "exit-node")
|
.filter((s) => s.type === "exit-node")
|
||||||
.map((s) => nodePosById[s.id])
|
.map((s) => nodePosById[s.id])
|
||||||
.filter((p): p is { x: number; y: number } => Boolean(p)),
|
.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 ─────────────────────────────────────────────────────────────────────
|
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
@@ -1373,10 +1378,12 @@ export default function NetworkMapPage() {
|
|||||||
type NodeDrag =
|
type NodeDrag =
|
||||||
| { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
|
| { 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: "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<NodeDrag | null>(null)
|
const nodeDragRef = useRef<NodeDrag | null>(null)
|
||||||
const suppressClickRef = useRef(false)
|
const suppressClickRef = useRef(false)
|
||||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||||
const [draggedSatKey, setDraggedSatKey] = useState<string | null>(null) // `${homeId}-${wanIdx}`
|
const [draggedSatKey, setDraggedSatKey] = useState<string | null>(null) // `${homeId}-${wanIdx}`
|
||||||
|
const [draggedSvcId, setDraggedSvcId] = useState<string | null>(null)
|
||||||
|
|
||||||
const zoomRef = useRef(zoom)
|
const zoomRef = useRef(zoom)
|
||||||
const panRef = useRef(pan)
|
const panRef = useRef(pan)
|
||||||
@@ -1486,6 +1493,8 @@ export default function NetworkMapPage() {
|
|||||||
|
|
||||||
if (nd.kind === "server") {
|
if (nd.kind === "server") {
|
||||||
setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
|
setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
|
||||||
|
} else if (nd.kind === "service") {
|
||||||
|
setServicePositions(prev => ({ ...prev, [nd.svcId]: { x: nx, y: ny } }))
|
||||||
} else {
|
} else {
|
||||||
setSatPositions(prev => {
|
setSatPositions(prev => {
|
||||||
const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
|
const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
|
||||||
@@ -1513,6 +1522,7 @@ export default function NetworkMapPage() {
|
|||||||
nodeDragRef.current = null
|
nodeDragRef.current = null
|
||||||
setDraggedNodeId(null)
|
setDraggedNodeId(null)
|
||||||
setDraggedSatKey(null)
|
setDraggedSatKey(null)
|
||||||
|
setDraggedSvcId(null)
|
||||||
return true
|
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 }
|
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
||||||
setDraggedSatKey(`${homeId}-${wanIdx}`)
|
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 ──────────────────────────────────────────────────
|
// ── Visibility / search ──────────────────────────────────────────────────
|
||||||
const sq = search.toLowerCase().trim()
|
const sq = search.toLowerCase().trim()
|
||||||
@@ -1748,10 +1762,10 @@ export default function NetworkMapPage() {
|
|||||||
? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow`
|
? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow`
|
||||||
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
|
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
|
||||||
</p>
|
</p>
|
||||||
{(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) && (
|
||||||
<div className="border-t border-border/50 mt-1 pt-1">
|
<div className="border-t border-border/50 mt-1 pt-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setNodePositions({}); setSatPositions({}) }}
|
onClick={() => { setNodePositions({}); setSatPositions({}); setServicePositions({}) }}
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs
|
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs
|
||||||
text-amber-400 hover:bg-amber-500/10 transition-colors">
|
text-amber-400 hover:bg-amber-500/10 transition-colors">
|
||||||
↺ Сбросить расположение
|
↺ Сбросить расположение
|
||||||
@@ -2005,7 +2019,7 @@ export default function NetworkMapPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* ── EN/JH → destination services ── */}
|
{/* ── EN → destination services ── */}
|
||||||
{visibleServiceEdges.map((edge) => {
|
{visibleServiceEdges.map((edge) => {
|
||||||
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
||||||
const to = servicePosById[edge.toId]
|
const to = servicePosById[edge.toId]
|
||||||
@@ -2018,8 +2032,13 @@ export default function NetworkMapPage() {
|
|||||||
}
|
}
|
||||||
const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
|
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 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 (
|
return (
|
||||||
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
|
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
|
||||||
|
<title>{pathTitle}</title>
|
||||||
<line
|
<line
|
||||||
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
||||||
stroke="#22d3ee"
|
stroke="#22d3ee"
|
||||||
@@ -2107,6 +2126,8 @@ export default function NetworkMapPage() {
|
|||||||
y={pos.y}
|
y={pos.y}
|
||||||
isSel={selectedService?.id === svc.id}
|
isSel={selectedService?.id === svc.id}
|
||||||
isVis
|
isVis
|
||||||
|
isDragged={draggedSvcId === svc.id}
|
||||||
|
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||||
selectService(svc)
|
selectService(svc)
|
||||||
@@ -2491,7 +2512,7 @@ export default function NetworkMapPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">С узлов</p>
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
|
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
|
||||||
const src = mapServers.find((s) => s.id === e.fromId)
|
const src = mapServers.find((s) => s.id === e.fromId)
|
||||||
@@ -2506,6 +2527,27 @@ export default function NetworkMapPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Клиенты</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{(() => {
|
||||||
|
const names = new Map<string, string>()
|
||||||
|
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 <p className="text-xs text-muted-foreground">—</p>
|
||||||
|
}
|
||||||
|
return [...names.values()].map((name) => (
|
||||||
|
<p key={name} className="text-xs font-mono truncate">{name}</p>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : selected ? (
|
) : selected ? (
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ try {
|
|||||||
const google = six.services?.find((s) => s.id === "svc:google")
|
const google = six.services?.find((s) => s.id === "svc:google")
|
||||||
assert.ok(google, "Google ≥ 5%")
|
assert.ok(google, "Google ≥ 5%")
|
||||||
assert.ok(google.share >= 0.05)
|
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 {
|
} finally {
|
||||||
resetFlowRingsForTests()
|
resetFlowRingsForTests()
|
||||||
resetIfaceCacheForTests()
|
resetIfaceCacheForTests()
|
||||||
@@ -327,4 +329,46 @@ try {
|
|||||||
resetFlowCatalogForTests()
|
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")
|
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
|
|||||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||||
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
|
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||||
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.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 DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
||||||
export const MAP_SERVICE_NODE_CAP = 20
|
export const MAP_SERVICE_NODE_CAP = 20
|
||||||
@@ -45,12 +45,29 @@ interface HopAcc {
|
|||||||
bytesRev: number
|
bytesRev: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FromAcc {
|
||||||
|
bytes: number
|
||||||
|
clients: Map<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
interface DstAcc {
|
interface DstAcc {
|
||||||
bytes: number
|
bytes: number
|
||||||
proto: number
|
proto: number
|
||||||
dstPort: number
|
dstPort: number
|
||||||
srcPort: number
|
srcPort: number
|
||||||
fromBytes: Map<string, number>
|
fromBytes: Map<string, FromAcc>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>()
|
||||||
|
if (client) clients.set(client.userId, client.name)
|
||||||
|
acc.fromBytes.set(exporterId, { bytes, clients })
|
||||||
}
|
}
|
||||||
|
|
||||||
let hopsCache: { key: string; at: number; dto: FlowMapHopsDto } | null = null
|
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 working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
|
||||||
const hops = new Map<string, HopAcc>()
|
const hops = new Map<string, HopAcc>()
|
||||||
const dstAcc = new Map<string, DstAcc>()
|
const dstAcc = new Map<string, DstAcc>()
|
||||||
|
const jhToEn = new Map<number, number>()
|
||||||
|
const enIds = new Set(topo.enNodes.map((n) => n.id))
|
||||||
let totalBytes = 0
|
let totalBytes = 0
|
||||||
|
|
||||||
for (const r of working) {
|
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)
|
const en = (enOut && enOut.id !== r.serverId ? enOut : null)
|
||||||
?? (enIn && enIn.id !== r.serverId ? enIn : null)
|
?? (enIn && enIn.id !== r.serverId ? enIn : null)
|
||||||
if (en) {
|
if (en) {
|
||||||
|
jhToEn.set(r.serverId, en.id)
|
||||||
const toId = String(en.id)
|
const toId = String(en.id)
|
||||||
const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev"
|
const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev"
|
||||||
const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined)
|
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
|
totalBytes += r.bytes
|
||||||
const svcFromId = String((enOut ?? enIn)?.id ?? r.serverId)
|
|
||||||
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
|
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
|
||||||
|
const client = resolveClient(topo, r.serverId, inName)
|
||||||
const prevDst = dstAcc.get(peer)
|
const prevDst = dstAcc.get(peer)
|
||||||
if (prevDst) {
|
if (prevDst) {
|
||||||
prevDst.bytes += r.bytes
|
prevDst.bytes += r.bytes
|
||||||
prevDst.fromBytes.set(svcFromId, (prevDst.fromBytes.get(svcFromId) ?? 0) + r.bytes)
|
bumpFrom(prevDst, String(r.serverId), r.bytes, client)
|
||||||
} else {
|
} else {
|
||||||
dstAcc.set(peer, {
|
const acc: DstAcc = {
|
||||||
bytes: r.bytes,
|
bytes: r.bytes,
|
||||||
proto: r.proto,
|
proto: r.proto,
|
||||||
dstPort: r.dstPort,
|
dstPort: r.dstPort,
|
||||||
srcPort: r.srcPort,
|
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<string, { label: string; category: string; bytes: number }>()
|
const svcTotals = new Map<string, { label: string; category: string; bytes: number }>()
|
||||||
const svcEdges = new Map<string, { fromId: string; toId: string; bytes: number; bytesFwd: number; bytesRev: number }>()
|
const svcEdges = new Map<string, {
|
||||||
|
fromId: string
|
||||||
|
toId: string
|
||||||
|
bytes: number
|
||||||
|
bytesFwd: number
|
||||||
|
bytesRev: number
|
||||||
|
clients: Map<string, string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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) {
|
for (const [dst, acc] of dstAcc) {
|
||||||
const ripe = lookupRipeCached(dst)
|
const ripe = lookupRipeCached(dst)
|
||||||
@@ -306,19 +343,23 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
|||||||
const prevSvc = svcTotals.get(toId)
|
const prevSvc = svcTotals.get(toId)
|
||||||
if (prevSvc) prevSvc.bytes += acc.bytes
|
if (prevSvc) prevSvc.bytes += acc.bytes
|
||||||
else svcTotals.set(toId, { label: classified.service, category: classified.category, 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 edgeKey = `${fromId}|${toId}`
|
||||||
const prevEdge = svcEdges.get(edgeKey)
|
const prevEdge = svcEdges.get(edgeKey)
|
||||||
if (prevEdge) {
|
if (prevEdge) {
|
||||||
prevEdge.bytes += bytes
|
prevEdge.bytes += from.bytes
|
||||||
prevEdge.bytesFwd += bytes
|
prevEdge.bytesFwd += from.bytes
|
||||||
|
for (const [id, name] of from.clients) prevEdge.clients.set(id, name)
|
||||||
} else {
|
} else {
|
||||||
svcEdges.set(edgeKey, {
|
svcEdges.set(edgeKey, {
|
||||||
fromId,
|
fromId,
|
||||||
toId,
|
toId,
|
||||||
bytes,
|
bytes: from.bytes,
|
||||||
bytesFwd: bytes,
|
bytesFwd: from.bytes,
|
||||||
bytesRev: 0,
|
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 keepSvc = new Set(services.map((s) => s.id))
|
||||||
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
||||||
.filter((e) => keepSvc.has(e.toId))
|
.filter((e) => keepSvc.has(e.toId))
|
||||||
.map((e) => ({
|
.map((e) => {
|
||||||
fromId: e.fromId,
|
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
|
||||||
toId: e.toId,
|
const first = clients[0]
|
||||||
bytes: e.bytes,
|
return {
|
||||||
bps: (e.bytes * 8) / windowSec,
|
fromId: e.fromId,
|
||||||
bpsFwd: (e.bytesFwd * 8) / windowSec,
|
toId: e.toId,
|
||||||
bpsRev: (e.bytesRev * 8) / windowSec,
|
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)
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
|
||||||
const listener = getFlowListenerState()
|
const listener = getFlowListenerState()
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
|
|||||||
)
|
)
|
||||||
case "google":
|
case "google":
|
||||||
return (
|
return (
|
||||||
<BrandSvg size={size}>
|
<svg width={size} height={size} viewBox="0 0 48 48" aria-hidden>
|
||||||
<path d="M12 11.2h10.2A10 10 0 1 0 12 22a9.6 9.6 0 0 0 6.6-2.6l-2.7-2.1A5.8 5.8 0 1 1 12 6.2c1.5 0 2.8.5 3.8 1.5l2.6-2.6A9.8 9.8 0 0 0 12 2Z" fill="#4285F4" />
|
<path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.7 32.7 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6.1 29.3 4 24 4 13 4 4 13 4 24s8.9 20 20 20c11 0 20-9 20-20 0-1.3-.1-2.7-.4-3.9z" />
|
||||||
<path d="M3.2 7.2 6.6 9.7A5.8 5.8 0 0 1 16 8.2l2.7-2.6A9.8 9.8 0 0 0 3.2 7.2Z" fill="#EA4335" />
|
<path fill="#FF3D00" d="M6.3 14.7 12.9 19.5C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z" />
|
||||||
<path d="M12 22a9.6 9.6 0 0 0 6.6-2.6l-2.7-2.1A5.8 5.8 0 0 1 6.5 14.4L3 17A9.8 9.8 0 0 0 12 22Z" fill="#34A853" />
|
<path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z" />
|
||||||
<path d="M21.8 12.2H12v3.6h5.6A5.5 5.5 0 0 1 15.9 17.3l2.7 2.1A9.4 9.4 0 0 0 22 12.2Z" fill="#FBBC05" />
|
<path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C36.9 39.2 44 34 44 24c0-1.3-.1-2.7-.4-3.9z" />
|
||||||
</BrandSvg>
|
</svg>
|
||||||
)
|
)
|
||||||
case "aws":
|
case "aws":
|
||||||
case "amazon":
|
case "amazon":
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ export const flowMapServiceEdgeDtoSchema = z.object({
|
|||||||
bps: z.number().nonnegative(),
|
bps: z.number().nonnegative(),
|
||||||
bpsFwd: z.number().nonnegative(),
|
bpsFwd: z.number().nonnegative(),
|
||||||
bpsRev: 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({
|
export const flowMapHopsDtoSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user