Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5188b2aff2 | ||
|
|
cd1fd2c9d3 | ||
|
|
77425cca32 | ||
|
|
29d245cde3 |
+353
-72
@@ -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"
|
||||
@@ -282,17 +285,113 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [
|
||||
]
|
||||
|
||||
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: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000 },
|
||||
{ fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000 },
|
||||
{ fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000 },
|
||||
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_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, 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, 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, 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, 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<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): 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)}%`
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -637,7 +736,9 @@ function ServiceNode({
|
||||
y,
|
||||
isSel,
|
||||
isVis,
|
||||
isDragged,
|
||||
onClick,
|
||||
onMouseDown,
|
||||
}: {
|
||||
label: string
|
||||
share: number
|
||||
@@ -645,15 +746,18 @@ function ServiceNode({
|
||||
y: number
|
||||
isSel: boolean
|
||||
isVis: boolean
|
||||
isDragged: boolean
|
||||
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 (
|
||||
<g
|
||||
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}
|
||||
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
|
||||
@@ -680,14 +784,9 @@ function ServiceNode({
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={isSel ? 2.2 : 1.4}
|
||||
/>
|
||||
<foreignObject x={-14} y={-24} width={28} height={28} style={{ overflow: "visible", pointerEvents: "none" }}>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28 }}
|
||||
{...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}
|
||||
>
|
||||
<ServiceBrandIcon label={label} size={22} />
|
||||
</div>
|
||||
</foreignObject>
|
||||
<g transform="translate(-11,-24)" pointerEvents="none">
|
||||
<ServiceBrandIcon label={label} size={22} />
|
||||
</g>
|
||||
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
|
||||
{label}
|
||||
</text>
|
||||
@@ -698,6 +797,59 @@ function ServiceNode({
|
||||
)
|
||||
}
|
||||
|
||||
function ServicePathList({
|
||||
paths,
|
||||
servers,
|
||||
services,
|
||||
highlight,
|
||||
viaMode,
|
||||
onToggle,
|
||||
}: {
|
||||
paths: FlowMapServicePath[]
|
||||
servers: Server[]
|
||||
services: FlowMapService[]
|
||||
highlight: { viaId: string; enId: string; serviceId: string } | null
|
||||
viaMode: "via" | "service"
|
||||
onToggle: (p: FlowMapServicePath) => void
|
||||
}) {
|
||||
if (paths.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">—</p>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{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 (
|
||||
<button
|
||||
key={rowKey}
|
||||
type="button"
|
||||
onClick={() => onToggle(p)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
|
||||
active ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono truncate min-w-0">{p.clientName} · {mid}</span>
|
||||
<span className="font-mono text-emerald-400 tabular-nums shrink-0">
|
||||
{formatNetflowRate({ bytes: p.bytes, bps: p.bps, bpsFwd: p.bps, bpsRev: 0 })}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
|
||||
x: number; y: number
|
||||
wan: { name: string; isp: string; maxDl: number; maxUl: number }
|
||||
@@ -938,6 +1090,7 @@ export default function NetworkMapPage() {
|
||||
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
|
||||
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
|
||||
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
|
||||
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
|
||||
const [mapSharePct, setMapSharePct] = useState(5)
|
||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||
@@ -1033,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)
|
||||
})
|
||||
@@ -1062,12 +1216,14 @@ export default function NetworkMapPage() {
|
||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||
const [selected, setSelected] = useState<Server | null>(null)
|
||||
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
||||
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
|
||||
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
// ── Node positions (overrides POS defaults) ─────────────────────────────────
|
||||
const [nodePositions, setNodePositions] = 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. */
|
||||
useEffect(() => {
|
||||
@@ -1079,6 +1235,7 @@ export default function NetworkMapPage() {
|
||||
// и перекрывают новый авто-лейаут на live-данных.
|
||||
setNodePositions({})
|
||||
setSatPositions({})
|
||||
setServicePositions({})
|
||||
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
|
||||
}
|
||||
} catch {
|
||||
@@ -1108,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
|
||||
@@ -1117,6 +1275,7 @@ export default function NetworkMapPage() {
|
||||
setMapHops([])
|
||||
setMapServices([])
|
||||
setMapServiceEdges([])
|
||||
setMapServicePaths([])
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1131,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) => {
|
||||
@@ -1335,9 +1495,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]! }))
|
||||
@@ -1351,13 +1511,16 @@ export default function NetworkMapPage() {
|
||||
})
|
||||
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
|
||||
|
||||
const servicePosById = placeServiceNodes(
|
||||
const autoServicePos = placeServiceNodes(
|
||||
visibleMapServices.map((s) => s.id),
|
||||
mapServers
|
||||
.filter((s) => s.type === "exit-node")
|
||||
.map((s) => nodePosById[s.id])
|
||||
.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 ─────────────────────────────────────────────────────────────────────
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
@@ -1373,10 +1536,12 @@ export default function NetworkMapPage() {
|
||||
type NodeDrag =
|
||||
| { 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: "service"; svcId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
|
||||
const nodeDragRef = useRef<NodeDrag | null>(null)
|
||||
const suppressClickRef = useRef(false)
|
||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||
const [draggedSatKey, setDraggedSatKey] = useState<string | null>(null) // `${homeId}-${wanIdx}`
|
||||
const [draggedSvcId, setDraggedSvcId] = useState<string | null>(null)
|
||||
|
||||
const zoomRef = useRef(zoom)
|
||||
const panRef = useRef(pan)
|
||||
@@ -1433,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()
|
||||
@@ -1486,6 +1657,8 @@ export default function NetworkMapPage() {
|
||||
|
||||
if (nd.kind === "server") {
|
||||
setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
|
||||
} else if (nd.kind === "service") {
|
||||
setServicePositions(prev => ({ ...prev, [nd.svcId]: { x: nx, y: ny } }))
|
||||
} else {
|
||||
setSatPositions(prev => {
|
||||
const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
|
||||
@@ -1513,6 +1686,7 @@ export default function NetworkMapPage() {
|
||||
nodeDragRef.current = null
|
||||
setDraggedNodeId(null)
|
||||
setDraggedSatKey(null)
|
||||
setDraggedSvcId(null)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1521,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 ──────────────────────────────────────────────────────
|
||||
@@ -1533,6 +1713,10 @@ export default function NetworkMapPage() {
|
||||
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
||||
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 ──────────────────────────────────────────────────
|
||||
const sq = search.toLowerCase().trim()
|
||||
@@ -1554,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)
|
||||
@@ -1562,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<SVGElement>, edge: WanJhEdge) {
|
||||
ev.stopPropagation()
|
||||
const home = mapServers.find((s) => s.id === edge.homeId)
|
||||
@@ -1748,10 +1946,10 @@ export default function NetworkMapPage() {
|
||||
? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow`
|
||||
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
|
||||
</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">
|
||||
<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
|
||||
text-amber-400 hover:bg-amber-500/10 transition-colors">
|
||||
↺ Сбросить расположение
|
||||
@@ -1865,13 +2063,23 @@ export default function NetworkMapPage() {
|
||||
setSelectedGreEdge(e)
|
||||
setSelected(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
setSelWanIdx(null)
|
||||
}
|
||||
return (
|
||||
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
|
||||
<g
|
||||
key={edgeId}
|
||||
opacity={dimmed ? 0.05 : (highlightedPath && !greMatchesPath(e, highlightedPath) ? 0.14 : 1)}
|
||||
style={{ transition: "opacity 0.3s" }}
|
||||
>
|
||||
<line
|
||||
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
|
||||
stroke={ts.stroke} strokeWidth={hopHasRate(flowHop) ? 2.6 : 1.5}
|
||||
stroke={ts.stroke}
|
||||
strokeWidth={
|
||||
highlightedPath && greMatchesPath(e, highlightedPath)
|
||||
? 3.4
|
||||
: hopHasRate(flowHop) ? 2.6 : 1.5
|
||||
}
|
||||
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
|
||||
opacity={ts.opacity}
|
||||
/>
|
||||
@@ -2005,50 +2213,6 @@ export default function NetworkMapPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── EN/JH → 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
|
||||
return (
|
||||
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
|
||||
<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
|
||||
@@ -2107,6 +2271,8 @@ export default function NetworkMapPage() {
|
||||
y={pos.y}
|
||||
isSel={selectedService?.id === svc.id}
|
||||
isVis
|
||||
isDragged={draggedSvcId === svc.id}
|
||||
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
|
||||
onClick={() => {
|
||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||
selectService(svc)
|
||||
@@ -2115,6 +2281,90 @@ 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 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 (
|
||||
<g
|
||||
key={`${edge.fromId}|${edge.toId}`}
|
||||
opacity={pathDim ? 0.12 : hl ? 1 : 0.72}
|
||||
style={{ transition: "opacity 0.3s" }}
|
||||
>
|
||||
<title>{pathTitle}</title>
|
||||
<line
|
||||
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={pathHit ? 3.2 : hopHasRate(hop) ? 2.4 : 1.4}
|
||||
strokeDasharray="6 5"
|
||||
opacity="0.85"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<line
|
||||
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
|
||||
stroke="#00000000"
|
||||
strokeWidth={14}
|
||||
strokeLinecap="round"
|
||||
style={{ cursor: "pointer" }}
|
||||
onPointerDown={(ev) => { ev.stopPropagation() }}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
if (svc) selectService(svc)
|
||||
}}
|
||||
/>
|
||||
{showAnimDots && hopHasRate(hop) && (
|
||||
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
|
||||
</circle>
|
||||
)}
|
||||
{hopHasRate(hop) && (
|
||||
<NetflowRateBadge
|
||||
mx={mx}
|
||||
my={my}
|
||||
hop={hop}
|
||||
onOpen={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const hit = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
if (hit) selectService(hit)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Hover tooltip ── */}
|
||||
{hoveredNode && !isDragging && (
|
||||
<SvgTooltip n={hoveredNode} />
|
||||
@@ -2491,7 +2741,7 @@ export default function NetworkMapPage() {
|
||||
</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">
|
||||
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
|
||||
const src = mapServers.find((s) => s.id === e.fromId)
|
||||
@@ -2506,6 +2756,20 @@ export default function NetworkMapPage() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
|
||||
<ServicePathList
|
||||
paths={mapServicePaths
|
||||
.filter((p) => p.serviceId === selectedService.id)
|
||||
.slice()
|
||||
.sort((a, b) => b.bps - a.bps)}
|
||||
servers={mapServers}
|
||||
services={mapServices}
|
||||
highlight={highlightedPath}
|
||||
viaMode="via"
|
||||
onToggle={togglePathHighlight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : selected ? (
|
||||
@@ -2733,6 +2997,23 @@ export default function NetworkMapPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selected.type === "jump-host" || selected.type === "exit-node") && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
|
||||
<ServicePathList
|
||||
paths={mapServicePaths
|
||||
.filter((p) => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources */}
|
||||
{(() => {
|
||||
const res = srvResMap[selected.id]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -384,6 +384,51 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "173.194.151.65",
|
||||
dst: "10.200.100.53",
|
||||
proto: 6,
|
||||
srcPort: 443,
|
||||
dstPort: 57182,
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
outIface: "2",
|
||||
},
|
||||
{
|
||||
src: "104.18.35.51",
|
||||
dst: "10.200.100.53",
|
||||
proto: 6,
|
||||
srcPort: 443,
|
||||
dstPort: 53880,
|
||||
bytes: 3_000,
|
||||
packets: 4,
|
||||
inIface: "2",
|
||||
outIface: "2",
|
||||
},
|
||||
])
|
||||
try {
|
||||
const rev = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||
const google = rev.conversationsList.find((r) => r.src === "173.194.151.65")
|
||||
const cf = rev.conversationsList.find((r) => r.src === "104.18.35.51")
|
||||
assert.equal(google?.service, "Google")
|
||||
assert.equal(google?.category, "Веб")
|
||||
assert.equal(cf?.service, "Cloudflare")
|
||||
assert.equal(cf?.category, "CDN")
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
|
||||
@@ -30,6 +30,7 @@ import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
|
||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||
import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
import {
|
||||
enGreIfaceNames,
|
||||
latestWireBps,
|
||||
@@ -171,6 +172,7 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||
const pathAcc = new Map<string, FlowPathRow>()
|
||||
const srcs = new Set<string>()
|
||||
const dsts = new Set<string>()
|
||||
const peers = new Set<string>()
|
||||
const matched: PendingFlowRow[] = []
|
||||
const skipHeavy = Boolean(q.skipHeavy)
|
||||
let bytesPayload = 0
|
||||
@@ -217,9 +219,11 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||
totalPackets += r.packets
|
||||
srcs.add(r.src)
|
||||
dsts.add(r.dst)
|
||||
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
|
||||
peers.add(peer)
|
||||
const app = applicationName(r.proto, r.dstPort, r.srcPort)
|
||||
const ripe = lookupRipeCached(r.dst)
|
||||
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe)
|
||||
const ripe = lookupRipeCached(peer)
|
||||
const classified = classifyFlowDst(peer, r.proto, r.dstPort, r.srcPort, ripe)
|
||||
bump(applications, app, r.bytes, r.packets)
|
||||
bump(protocols, protoName(r.proto), r.bytes, r.packets)
|
||||
bump(sources, r.src, r.bytes, r.packets)
|
||||
@@ -345,7 +349,7 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||
}
|
||||
}
|
||||
|
||||
enqueueRipeMisses(dsts)
|
||||
enqueueRipeMisses(peers)
|
||||
|
||||
const conversationsList = [...conv.values()]
|
||||
.map((t) => {
|
||||
|
||||
@@ -27,6 +27,9 @@ assert.equal(brandByAsn(16509)?.service, "AWS")
|
||||
assert.equal(brandByAsn(57976)?.service, "Blizzard")
|
||||
assert.equal(brandByAsn(401115)?.service, "ChatGPT")
|
||||
assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare")
|
||||
assert.equal(lookupBrand("104.18.35.51", 0)?.service, "Cloudflare")
|
||||
assert.equal(lookupBrand("173.194.151.65", 0)?.service, "Google")
|
||||
assert.equal(lookupBrand("8.8.8.8", 0)?.service, "Google")
|
||||
assert.equal(lookupBrand("203.0.113.9", 64500), null)
|
||||
assert.equal(OTHER_SERVICE, "Прочее")
|
||||
assert.equal(isNamedInternetService("Google", "Веб"), true)
|
||||
|
||||
@@ -56,13 +56,23 @@ const ASN_HQ_COUNTRY = new Map<number, string>([
|
||||
[211157, "NL"],
|
||||
])
|
||||
|
||||
const GOOGLE: BrandHit = { service: "Google", category: "Веб" }
|
||||
const CLOUDFLARE: BrandHit = { service: "Cloudflare", category: "CDN" }
|
||||
const YOUTUBE: BrandHit = { service: "YouTube", category: "Видео / стриминг" }
|
||||
|
||||
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
||||
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: { service: "Cloudflare", category: "CDN" } },
|
||||
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: { service: "Cloudflare", category: "CDN" } },
|
||||
{ cidr: "172.64.0.0/13", prefixLen: 13, hit: { service: "Cloudflare", category: "CDN" } },
|
||||
{ cidr: "162.158.0.0/15", prefixLen: 15, hit: { service: "Cloudflare", category: "CDN" } },
|
||||
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: { service: "YouTube", category: "Видео / стриминг" } },
|
||||
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: { service: "YouTube", category: "Видео / стриминг" } },
|
||||
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
||||
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: CLOUDFLARE },
|
||||
{ cidr: "172.64.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
||||
{ cidr: "162.158.0.0/15", prefixLen: 15, hit: CLOUDFLARE },
|
||||
{ cidr: "8.8.8.0/24", prefixLen: 24, hit: GOOGLE },
|
||||
{ cidr: "8.8.4.0/24", prefixLen: 24, hit: GOOGLE },
|
||||
{ cidr: "173.194.0.0/16", prefixLen: 16, hit: GOOGLE },
|
||||
{ cidr: "172.217.0.0/16", prefixLen: 16, hit: GOOGLE },
|
||||
{ cidr: "74.125.0.0/16", prefixLen: 16, hit: GOOGLE },
|
||||
{ cidr: "142.250.0.0/15", prefixLen: 15, hit: GOOGLE },
|
||||
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: YOUTUBE },
|
||||
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
|
||||
].sort((a, b) => b.prefixLen - a.prefixLen)
|
||||
|
||||
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
||||
|
||||
@@ -36,6 +36,10 @@ const google = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
||||
assert.equal(google.service, "Google")
|
||||
assert.equal(google.category, "Веб")
|
||||
|
||||
const googleCidr = classifyFlowDst("173.194.151.65", 6, 57182, 443, null)
|
||||
assert.equal(googleCidr.service, "Google")
|
||||
assert.equal(googleCidr.category, "Веб")
|
||||
|
||||
const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
||||
prefix: "173.194.0.0/16",
|
||||
asn: 15169,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { classifyFlowDst } from "./traffic-flow-classify.js"
|
||||
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
|
||||
type SqliteHandle = InstanceType<typeof Database>
|
||||
|
||||
@@ -239,9 +240,10 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
|
||||
const flow = normalizeParsedFlow(raw)
|
||||
addToTick(serverId, flow, flow.bytes)
|
||||
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
|
||||
const ripe = lookupRipeCached(flow.dst)
|
||||
if (flow.dst && !ripe) ripeMisses.push(flow.dst)
|
||||
const classified = classifyFlowDst(flow.dst, flow.proto, flow.dstPort, flow.srcPort, ripe)
|
||||
const peer = pickInternetPeer(flow.src, flow.dst, flow.srcPort, flow.dstPort)
|
||||
const ripe = lookupRipeCached(peer)
|
||||
if (peer && !ripe) ripeMisses.push(peer)
|
||||
const classified = classifyFlowDst(peer, flow.proto, flow.dstPort, flow.srcPort, ripe)
|
||||
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
|
||||
const country = ripe?.ok && isIsoCountry(ripe.country)
|
||||
? ripe.country
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { isNonPublicIp, pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
|
||||
assert.equal(isNonPublicIp("10.200.100.53"), true)
|
||||
assert.equal(isNonPublicIp("173.194.151.65"), false)
|
||||
|
||||
assert.equal(
|
||||
pickInternetPeer("173.194.151.65", "10.200.100.53", 443, 57182),
|
||||
"173.194.151.65",
|
||||
"reverse IPFIX: Google:443 → RFC1918",
|
||||
)
|
||||
assert.equal(
|
||||
pickInternetPeer("10.200.100.53", "104.18.35.51", 53880, 443),
|
||||
"104.18.35.51",
|
||||
"client → Cloudflare:443",
|
||||
)
|
||||
assert.equal(pickInternetPeer("10.100.1.17", "8.8.8.8", 51234, 443), "8.8.8.8")
|
||||
assert.equal(
|
||||
pickInternetPeer("1.1.1.1", "8.8.8.8", 443, 51234),
|
||||
"1.1.1.1",
|
||||
"оба публичные — сторона с well-known портом",
|
||||
)
|
||||
assert.equal(pickInternetPeer("10.1.1.1", "10.2.2.2", 443, 80), "10.2.2.2")
|
||||
|
||||
console.log("traffic-flow-ip.test.ts: ok")
|
||||
@@ -52,3 +52,23 @@ export function isNonPublicIp(ip: string): boolean {
|
||||
|| inRange("255.255.255.255/32")
|
||||
)
|
||||
}
|
||||
|
||||
const PEER_WELL_KNOWN_PORTS = new Set([80, 443, 53, 853])
|
||||
|
||||
/**
|
||||
* Интернет-сторона потока: у IPFIX сервис часто в src (Google:443 → RFC1918:ephemeral).
|
||||
* Классифицировать этот IP, не слепой dst.
|
||||
*/
|
||||
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string {
|
||||
const srcPub = !isNonPublicIp(src)
|
||||
const dstPub = !isNonPublicIp(dst)
|
||||
if (srcPub && !dstPub) return src
|
||||
if (dstPub && !srcPub) return dst
|
||||
if (srcPub && dstPub) {
|
||||
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPort)
|
||||
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPort)
|
||||
if (srcWk && !dstWk) return src
|
||||
if (dstWk && !srcWk) return dst
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
@@ -208,7 +208,9 @@ try {
|
||||
const google = six.services?.find((s) => s.id === "svc:google")
|
||||
assert.ok(google, "Google ≥ 5%")
|
||||
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 {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
@@ -278,4 +280,138 @@ try {
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedFlowTopologyForTests(topo)
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "gre-client" },
|
||||
{ ".id": "*3", name: "gre-jh-en" },
|
||||
])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "173.194.151.65",
|
||||
dst: "10.200.100.53",
|
||||
proto: 6,
|
||||
srcPort: 443,
|
||||
dstPort: 57182,
|
||||
bytes: 9_000,
|
||||
packets: 90,
|
||||
inIface: "2",
|
||||
outIface: "3",
|
||||
nextHop: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
src: "104.18.35.51",
|
||||
dst: "10.200.100.53",
|
||||
proto: 6,
|
||||
srcPort: 443,
|
||||
dstPort: 53880,
|
||||
bytes: 1_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
outIface: "3",
|
||||
nextHop: "198.51.100.1",
|
||||
},
|
||||
])
|
||||
try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const rev = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||
assert.ok(rev.services?.some((s) => s.id === "svc:google"), "реверс Google:443 → 10.x")
|
||||
assert.ok(rev.services?.some((s) => s.id === "svc:cloudflare"), "реверс Cloudflare:443 → 10.x")
|
||||
assert.ok(rev.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9"))
|
||||
} finally {
|
||||
seedFlowTopologyForTests(null)
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
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()
|
||||
}
|
||||
|
||||
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")
|
||||
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()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||
|
||||
@@ -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"
|
||||
@@ -12,9 +12,10 @@ import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
||||
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.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 MAP_SERVICE_NODE_CAP = 20
|
||||
@@ -44,12 +45,45 @@ interface HopAcc {
|
||||
bytesRev: number
|
||||
}
|
||||
|
||||
interface ClientAcc {
|
||||
name: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
interface FromAcc {
|
||||
bytes: number
|
||||
clients: Map<string, ClientAcc>
|
||||
}
|
||||
|
||||
interface DstAcc {
|
||||
bytes: number
|
||||
proto: number
|
||||
dstPort: number
|
||||
srcPort: number
|
||||
fromBytes: Map<string, number>
|
||||
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
|
||||
bumpClient(prev.clients, bytes, client)
|
||||
return
|
||||
}
|
||||
const clients = new Map<string, ClientAcc>()
|
||||
bumpClient(clients, bytes, client)
|
||||
acc.fromBytes.set(exporterId, { bytes, clients })
|
||||
}
|
||||
|
||||
let hopsCache: { key: string; at: number; dto: FlowMapHopsDto } | null = null
|
||||
@@ -189,6 +223,8 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
|
||||
const hops = new Map<string, HopAcc>()
|
||||
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
|
||||
|
||||
for (const r of working) {
|
||||
@@ -240,6 +276,7 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
const en = (enOut && enOut.id !== r.serverId ? enOut : null)
|
||||
?? (enIn && enIn.id !== r.serverId ? enIn : null)
|
||||
if (en) {
|
||||
jhToEn.set(r.serverId, en.id)
|
||||
const toId = String(en.id)
|
||||
const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev"
|
||||
const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined)
|
||||
@@ -277,24 +314,74 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
}
|
||||
|
||||
totalBytes += r.bytes
|
||||
const svcFromId = String((enOut ?? enIn)?.id ?? r.serverId)
|
||||
const prevDst = dstAcc.get(r.dst)
|
||||
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
|
||||
const client = resolveClient(topo, r.serverId, inName)
|
||||
const prevDst = dstAcc.get(peer)
|
||||
if (prevDst) {
|
||||
prevDst.bytes += r.bytes
|
||||
prevDst.fromBytes.set(svcFromId, (prevDst.fromBytes.get(svcFromId) ?? 0) + r.bytes)
|
||||
bumpFrom(prevDst, String(r.serverId), r.bytes, client)
|
||||
} else {
|
||||
dstAcc.set(r.dst, {
|
||||
const acc: DstAcc = {
|
||||
bytes: r.bytes,
|
||||
proto: r.proto,
|
||||
dstPort: r.dstPort,
|
||||
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 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>
|
||||
}>()
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -304,21 +391,49 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
const prevSvc = svcTotals.get(toId)
|
||||
if (prevSvc) prevSvc.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 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 += bytes
|
||||
prevEdge.bytesFwd += bytes
|
||||
prevEdge.bytes += from.bytes
|
||||
prevEdge.bytesFwd += from.bytes
|
||||
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
|
||||
} else {
|
||||
svcEdges.set(edgeKey, {
|
||||
fromId,
|
||||
toId,
|
||||
bytes,
|
||||
bytesFwd: bytes,
|
||||
bytes: from.bytes,
|
||||
bytesFwd: from.bytes,
|
||||
bytesRev: 0,
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,16 +455,37 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
const keepSvc = new Set(services.map((s) => s.id))
|
||||
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
||||
.filter((e) => keepSvc.has(e.toId))
|
||||
.map((e) => ({
|
||||
fromId: e.fromId,
|
||||
toId: e.toId,
|
||||
bytes: e.bytes,
|
||||
bps: (e.bytes * 8) / windowSec,
|
||||
bpsFwd: (e.bytesFwd * 8) / windowSec,
|
||||
bpsRev: (e.bytesRev * 8) / windowSec,
|
||||
}))
|
||||
.map((e) => {
|
||||
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
|
||||
const first = clients[0]
|
||||
return {
|
||||
fromId: e.fromId,
|
||||
toId: e.toId,
|
||||
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)
|
||||
|
||||
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()]
|
||||
@@ -361,6 +497,7 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
totalBytes,
|
||||
services,
|
||||
serviceEdges,
|
||||
servicePaths,
|
||||
mapServiceMinSharePct: minSharePct,
|
||||
dedupApplied: wantDedup,
|
||||
excludeMeshApplied: excludeMesh,
|
||||
|
||||
@@ -36,12 +36,12 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
|
||||
)
|
||||
case "google":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<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 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 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 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" />
|
||||
</BrandSvg>
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" aria-hidden>
|
||||
<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 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 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 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" />
|
||||
</svg>
|
||||
)
|
||||
case "aws":
|
||||
case "amazon":
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Generated
+15
@@ -14259,6 +14259,21 @@
|
||||
"dependencies": {
|
||||
"zod": "^4.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +281,24 @@ export const flowMapServiceEdgeDtoSchema = z.object({
|
||||
bps: z.number().nonnegative(),
|
||||
bpsFwd: 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 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({
|
||||
@@ -291,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(),
|
||||
@@ -313,4 +332,5 @@ export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema>
|
||||
export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
|
||||
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
|
||||
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
|
||||
export type FlowMapServicePath = z.infer<typeof flowMapServicePathDtoSchema>
|
||||
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user