Compare commits

...
5 Commits
Author SHA1 Message Date
DenozordecandCursor 5188b2aff2 fix(network-map): обрезать пунктир до сервиса и показать пути клиентов
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m58s
Docker images / frontend-image (push) Successful in 3m17s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 13s
Линия от обода EN до рамки сервиса; таблица клиент, узел и сервис с подсветкой на карте.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:39:18 +07:00
DenozordecandCursor cd1fd2c9d3 fix(network-map): рисовать пунктир и скорость до сервиса
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m41s
Docker images / frontend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
Якорь hop на EN даже без nextHop; линия ARN\to Google с NetflowRateBadge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:02:28 +07:00
DenozordecandCursor 77425cca32 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
Пунктир от EN, а не от JH; перетаскивание узлов сервисов; иконка Google без foreignObject.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 16:10:39 +07:00
DenozordecandCursor 29d245cde3 feat(traffic): enhance flow analytics and brand classification
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m12s
Docker images / frontend-image (push) Successful in 4m3s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 12s
- Introduced `pickInternetPeer` function to improve IP classification by selecting the appropriate public IP from source and destination.
- Updated `buildFlowAnalytics` and related functions to utilize the new peer selection logic, enhancing accuracy in flow analytics.
- Added tests for new classifications and ensured existing tests cover new scenarios for Google and Cloudflare.
- Refactored traffic flow brand mappings to include additional CIDR ranges for Google and Cloudflare.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 15:35:52 +07:00
DenozordecandCursor 7a491a325d fix(network-map): убрать тяжёлую классификацию из poll карты
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m53s
Docker images / frontend-image (push) Successful in 3m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 12s
Не сканировать RIPE и каталог по каждой строке окна; сервисы по уникальным dst и кэшу ASN. Порог доли на карте настраиваемый и отключаемый.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:17:04 +07:00
23 changed files with 1163 additions and 157 deletions
+370 -77
View File
@@ -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,8 @@ 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>>({})
const [dataError, setDataError] = useState<string | null>(null)
@@ -1032,6 +1186,8 @@ export default function NetworkMapPage() {
setMapHops([])
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapSharePct(5)
setDataError(null)
})
return
@@ -1060,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(() => {
@@ -1077,6 +1235,7 @@ export default function NetworkMapPage() {
// и перекрывают новый авто-лейаут на live-данных.
setNodePositions({})
setSatPositions({})
setServicePositions({})
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
}
} catch {
@@ -1106,6 +1265,8 @@ export default function NetworkMapPage() {
setMapHops([])
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapSharePct(5)
})
return
}
@@ -1114,29 +1275,35 @@ export default function NetworkMapPage() {
setMapHops([])
setMapServices([])
setMapServiceEdges([])
setMapServicePaths([])
})
return
}
let cancelled = false
let ac: AbortController | null = null
const tick = () => {
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m")
ac?.abort()
ac = new AbortController()
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
.then((res) => {
if (cancelled) return
setMapHops(res.hops ?? [])
setMapServices(res.services ?? [])
setMapServiceEdges(res.serviceEdges ?? [])
setMapServicePaths(res.servicePaths ?? [])
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
})
.catch(() => {
.catch((err: unknown) => {
if (cancelled) return
setMapHops([])
setMapServices([])
setMapServiceEdges([])
const name = err instanceof Error ? err.name : ""
if (name === "AbortError") return
})
}
tick()
const id = window.setInterval(tick, 4000)
return () => {
cancelled = true
ac?.abort()
window.clearInterval(id)
}
}, [useLiveData, showNetflow, showServices, apiFetch])
@@ -1328,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]! }))
@@ -1344,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)
@@ -1366,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)
@@ -1426,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()
@@ -1479,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] ?? []))]
@@ -1506,6 +1686,7 @@ export default function NetworkMapPage() {
nodeDragRef.current = null
setDraggedNodeId(null)
setDraggedSatKey(null)
setDraggedSvcId(null)
return true
}
@@ -1514,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 ──────────────────────────────────────────────────────
@@ -1526,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()
@@ -1547,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)
@@ -1555,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)
@@ -1736,10 +1941,15 @@ export default function NetworkMapPage() {
</span>
</button>
))}
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0) && (
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
{mapSharePct > 0
? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
</p>
{(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">
Сбросить расположение
@@ -1853,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}
/>
@@ -1993,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
@@ -2095,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)
@@ -2103,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} />
@@ -2479,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)
@@ -2494,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 ? (
@@ -2721,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]
+1 -1
View File
@@ -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": {
+8
View File
@@ -146,6 +146,7 @@ CREATE TABLE IF NOT EXISTS traffic_flow_settings (
hub_server_id INTEGER,
retention_hours INTEGER NOT NULL DEFAULT 24,
top_n INTEGER NOT NULL DEFAULT 200,
map_service_min_share_pct REAL NOT NULL DEFAULT 5,
last_datagram_at TEXT,
last_exporter_ip TEXT,
last_error TEXT,
@@ -834,6 +835,13 @@ SELECT 1, 0, '10.255.254.1', 4739, 51821, '10.255.254.0/24'
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
`)
{
const flowSettingsCols = sqlite.prepare(`PRAGMA table_info('traffic_flow_settings')`).all() as Array<{ name?: string }>
if (!flowSettingsCols.some((c) => c.name === "map_service_min_share_pct")) {
sqlite.exec(`ALTER TABLE traffic_flow_settings ADD COLUMN map_service_min_share_pct REAL NOT NULL DEFAULT 5`)
}
}
sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
+1
View File
@@ -173,6 +173,7 @@ export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
hubServerId: integer("hub_server_id"),
retentionHours: integer("retention_hours").notNull().default(24),
topN: integer("top_n").notNull().default(200),
mapServiceMinSharePct: real("map_service_min_share_pct").notNull().default(5),
lastDatagramAt: text("last_datagram_at"),
lastExporterIp: text("last_exporter_ip"),
lastError: text("last_error"),
@@ -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)
+16 -6
View File
@@ -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,
+5 -3
View File
@@ -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")
+20
View File
@@ -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
}
@@ -4,7 +4,7 @@ import {
ingestParsedFlowsForServerForTests,
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowMapHops } from "./traffic-flow-map-hops.js"
import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
@@ -114,6 +114,7 @@ ingestParsedFlowsForServerForTests(3, [
])
try {
resetFlowMapHopsCacheForTests()
const def = buildFlowMapHops({ minutes: 5 })
assert.equal(def.excludeOverlayApplied, true)
assert.equal(def.excludeMeshApplied, true)
@@ -142,6 +143,7 @@ try {
assert.ok(wan, "WAN hop from home-router")
assert.equal(wan.bytes, 3000)
resetFlowMapHopsCacheForTests()
const withAll = buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7")
assert.ok(overlayIface && overlayIface.bytes >= 5_000_000)
@@ -200,12 +202,15 @@ ingestParsedFlowsForServerForTests(7, [
payloadFlow("203.0.113.50", 9400),
])
try {
const six = buildFlowMapHops({ minutes: 5 })
resetFlowMapHopsCacheForTests()
const six = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(six.totalBytes, 10_000)
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()
@@ -227,9 +232,13 @@ ingestParsedFlowsForServerForTests(7, [
payloadFlow("203.0.113.50", 9600),
])
try {
const four = buildFlowMapHops({ minutes: 5 })
resetFlowMapHopsCacheForTests()
const four = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(four.totalBytes, 10_000)
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
resetFlowMapHopsCacheForTests()
const off = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
@@ -260,7 +269,8 @@ ingestParsedFlowsForServerForTests(7, [
payloadFlow("203.0.113.50", 1000),
])
try {
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false })
resetFlowMapHopsCacheForTests()
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false, minSharePct: 0 })
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
} finally {
seedFlowTopologyForTests(null)
@@ -270,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")
+274 -42
View File
@@ -1,21 +1,25 @@
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 { flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import {
isNamedInternetService,
lookupBrand,
mapServiceNodeId,
} from "./traffic-flow-brands.js"
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
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 { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
import { loadFlowTopology, resolveEn } from "./traffic-flow-topology.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, resolveClient, resolveEn } from "./traffic-flow-topology.js"
export const MAP_SERVICE_SHARE_THRESHOLD = 0.05
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
export const MAP_SERVICE_NODE_CAP = 20
const HOPS_CACHE_TTL_MS = 2000
export interface FlowMapHopsQuery {
minutes: number
@@ -25,6 +29,8 @@ export interface FlowMapHopsQuery {
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
/** Переопределение порога (тесты). Иначе из настроек NetFlow. */
minSharePct?: number
}
interface HopAcc {
@@ -39,6 +45,72 @@ 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, 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
export function resetFlowMapHopsCacheForTests(): void {
hopsCache = null
}
export function clampMapServiceMinSharePct(n: unknown): number {
const v = typeof n === "number" ? n : Number(n)
if (!Number.isFinite(v)) return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
return Math.min(100, Math.max(0, v))
}
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
return JSON.stringify({
minutes: q.minutes,
serverId: q.serverId ?? null,
userId: q.userId ?? null,
iface: q.iface ?? null,
dedup: q.dedup !== false,
excludeMesh: q.excludeMesh !== false,
excludeOverlay: q.excludeOverlay !== false,
minSharePct,
})
}
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
if (!userId) return null
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
@@ -89,8 +161,36 @@ function toHop(a: HopAcc, windowSec: number): FlowMapHop {
}
}
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
/** Имя бренда без каталога EvoBGP — только ASN/CIDR кэш + proto. */
function classifyMapDstLite(
dst: string,
proto: number,
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
): { service: string; category: string } | null {
if (proto === 47 || proto === 50) return null
const app = applicationName(proto, dstPort, srcPort)
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
if (/youtube/i.test(ripe?.holder ?? "")) {
return { service: "YouTube", category: "Видео / стриминг" }
}
const brand = lookupBrand(dst, ripe?.asn ?? 0)
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
return brand
}
function resolveMinSharePct(q: FlowMapHopsQuery): number {
if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
try {
const row = getTrafficFlowSettingsRow() as { mapServiceMinSharePct?: number }
return clampMapServiceMinSharePct(row.mapServiceMinSharePct ?? DEFAULT_MAP_SERVICE_MIN_SHARE_PCT)
} catch {
return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
}
}
function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): FlowMapHopsDto {
const windowSec = Math.max(60, q.minutes * 60)
const raw = listFlowRowsForWindow(q.minutes)
const allow = q.userId ? userIfaceAllow(q.userId) : null
@@ -122,13 +222,11 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
const hops = new Map<string, HopAcc>()
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 dsts = new Set<string>()
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
refreshFlowCatalogInBackground()
for (const r of working) {
const inRes = resolveIfaceName(r.serverId, r.inIface)
const outRes = resolveIfaceName(r.serverId, r.outIface)
@@ -178,6 +276,7 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
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)
@@ -215,37 +314,131 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
}
totalBytes += r.bytes
dsts.add(r.dst)
const ripe = lookupRipeCached(r.dst)
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe)
if (isNamedInternetService(classified.service, classified.category)) {
const toId = mapServiceNodeId(classified.service)
const prevSvc = svcTotals.get(toId)
if (prevSvc) prevSvc.bytes += r.bytes
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: r.bytes })
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
bumpFrom(prevDst, String(r.serverId), r.bytes, client)
} else {
const acc: DstAcc = {
bytes: r.bytes,
proto: r.proto,
dstPort: r.dstPort,
srcPort: r.srcPort,
fromBytes: new Map(),
}
bumpFrom(acc, String(r.serverId), r.bytes, client)
dstAcc.set(peer, acc)
}
}
const svcEn = enOut ?? enIn
const svcFromId = svcEn ? String(svcEn.id) : fromId
const edgeKey = `${svcFromId}|${toId}`
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
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)
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
if (!classified) continue
const toId = mapServiceNodeId(classified.service)
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 [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 += r.bytes
prevEdge.bytesFwd += r.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: svcFromId,
fromId,
toId,
bytes: r.bytes,
bytesFwd: r.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,
})
}
}
}
}
enqueueRipeMisses(dsts)
const services: FlowMapService[] = [...svcTotals.entries()]
const minShare = minSharePct / 100
let services: FlowMapService[] = [...svcTotals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
@@ -254,34 +447,73 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
bps: (s.bytes * 8) / windowSec,
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
}))
.filter((s) => s.share >= MAP_SERVICE_SHARE_THRESHOLD)
.sort((a, b) => b.bytes - a.bytes)
if (minSharePct > 0) {
services = services.filter((s) => s.share >= minShare)
}
services = services.slice(0, MAP_SERVICE_NODE_CAP)
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()]
.map((a) => toHop(a, windowSec))
.sort((a, b) => b.bytes - a.bytes),
.sort((a, b) => a.bytes === b.bytes ? 0 : b.bytes - a.bytes),
live: listener.bound,
rangeMinutes: q.minutes,
windowSec,
totalBytes,
services,
serviceEdges,
servicePaths,
mapServiceMinSharePct: minSharePct,
dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh,
excludeOverlayApplied: excludeOverlay,
}
}
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
const minSharePct = resolveMinSharePct(q)
const key = hopsQueryKey(q, minSharePct)
const now = Date.now()
if (hopsCache && hopsCache.key === key && now - hopsCache.at < HOPS_CACHE_TTL_MS) {
return hopsCache.dto
}
const dto = buildFlowMapHopsUncached(q, minSharePct)
hopsCache = { key, at: now, dto }
return dto
}
@@ -7,6 +7,7 @@ import {
lookupRipeCached,
resetRipeCacheForTests,
ripeFetchCountForTests,
ripeLastCandidateCountForTests,
seedRipeCacheForTests,
setRipeFetchForTests,
} from "./traffic-flow-ripe.js"
@@ -100,4 +101,36 @@ await flushRipeQueueForTests()
assert.equal(lookupRipeCached("1.0.0.1")?.country, "US")
assert.equal(lookupRipeCached("1.0.0.1")?.asn, 13335)
resetRipeCacheForTests()
disableRipePersistForTests()
for (let i = 0; i < 3000; i++) {
const o2 = Math.floor(i / 256)
const o3 = i % 256
seedRipeCacheForTests({
prefix: `203.${o2}.${o3}.0/24`,
asn: 64500,
country: "NL",
lat: null,
lng: null,
holder: "NOISE",
ok: true,
fetchedAt: Date.now(),
})
}
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(lookupRipeCached("8.8.8.8")?.asn, 15169)
assert.ok(
ripeLastCandidateCountForTests() < 8,
`index should not scan all prefixes, got ${ripeLastCandidateCountForTests()}`,
)
console.log("traffic-flow-ripe.test.ts: ok")
+79 -13
View File
@@ -1,5 +1,5 @@
import { sqliteDatabase } from "../db/index.js"
import { ipInCidrV4, ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
import { ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
import { resolveRipeCountry } from "./traffic-flow-brands.js"
export interface FlowIpMeta {
@@ -28,6 +28,18 @@ const queue: string[] = []
const queued = new Set<string>()
const recentFetches: number[] = []
interface RipeIndexed {
entry: FlowIpMeta
net: number
mask: number
prefixLen: number
}
/** /24 → кандидаты с prefixLen ≥ 24. Более широкие префиксы — в `wideIndex`. */
const v24Index = new Map<number, RipeIndexed[]>()
const wideIndex: RipeIndexed[] = []
let lastCandidateCount = 0
let persistEnabled = true
let enqueueEnabled = true
let loaded = false
@@ -50,6 +62,9 @@ export function resetRipeCacheForTests(): void {
queue.length = 0
queued.clear()
recentFetches.length = 0
v24Index.clear()
wideIndex.length = 0
lastCandidateCount = 0
loaded = persistEnabled ? false : true
workerRunning = false
fetchCount = 0
@@ -58,10 +73,15 @@ export function resetRipeCacheForTests(): void {
}
export function seedRipeCacheForTests(entry: FlowIpMeta): void {
mem.set(entry.prefix, { ...entry })
remember(entry)
loaded = true
}
/** Сколько CIDR смотрели в последнем lookup (для теста индекса /24). */
export function ripeLastCandidateCountForTests(): number {
return lastCandidateCount
}
export function setRipeFetchForTests(fn: typeof fetch): void {
fetchImpl = fn
fetchCount = 0
@@ -87,6 +107,48 @@ function isFresh(entry: FlowIpMeta): boolean {
return Date.now() - entry.fetchedAt < ttlMs(entry.ok)
}
function unindexPrefix(prefix: string): void {
const parsed = parseCidrV4(prefix)
if (!parsed) return
if (parsed.prefixLen >= 24) {
const key = parsed.net >>> 8
const list = v24Index.get(key)
if (!list) return
const next = list.filter((row) => row.entry.prefix !== prefix)
if (next.length) v24Index.set(key, next)
else v24Index.delete(key)
return
}
const idx = wideIndex.findIndex((row) => row.entry.prefix === prefix)
if (idx >= 0) wideIndex.splice(idx, 1)
}
function indexEntry(entry: FlowIpMeta): void {
const parsed = parseCidrV4(entry.prefix)
if (!parsed) return
const row: RipeIndexed = {
entry,
net: parsed.net,
mask: parsed.mask,
prefixLen: parsed.prefixLen,
}
if (parsed.prefixLen >= 24) {
const key = parsed.net >>> 8
const list = v24Index.get(key)
if (list) list.push(row)
else v24Index.set(key, [row])
return
}
wideIndex.push(row)
}
function remember(entry: FlowIpMeta): void {
const prev = mem.get(entry.prefix)
if (prev) unindexPrefix(prev.prefix)
mem.set(entry.prefix, entry)
indexEntry(entry)
}
function loadSqlite(): void {
if (loaded || !persistEnabled) {
loaded = true
@@ -111,7 +173,7 @@ function loadSqlite(): void {
const fetchedAt = Date.parse(r.fetched_at)
const asn = Number(r.asn ?? 0) || 0
const holder = r.holder || ""
mem.set(r.prefix, {
remember({
prefix: r.prefix,
asn,
country: resolveRipeCountry(r.country || "", asn, holder) || "—",
@@ -193,20 +255,24 @@ function negative(prefix: string): FlowIpMeta {
export function lookupRipeCached(ip: string): FlowIpMeta | null {
loadSqlite()
const trimmed = String(ip ?? "").trim()
lastCandidateCount = 0
if (!trimmed) return null
if (isNonPublicIp(trimmed)) {
return negative(`${trimmed.includes(":") ? trimmed : trimmed}/32`)
}
const addr = ipv4ToInt(trimmed)
if (addr == null) return null
const bucket = v24Index.get(addr >>> 8)
const candidates = bucket ? bucket.concat(wideIndex) : wideIndex
lastCandidateCount = candidates.length
let best: FlowIpMeta | null = null
let bestLen = -1
for (const entry of mem.values()) {
if (!isFresh(entry)) continue
const parsed = parseCidrV4(entry.prefix)
if (!parsed) continue
if (!ipInCidrV4(trimmed, entry.prefix)) continue
if (parsed.prefixLen > bestLen) {
best = entry
bestLen = parsed.prefixLen
for (const row of candidates) {
if (!isFresh(row.entry)) continue
if (((addr & row.mask) >>> 0) !== row.net) continue
if (row.prefixLen > bestLen) {
best = row.entry
bestLen = row.prefixLen
}
}
return best
@@ -316,13 +382,13 @@ async function resolveIp(ip: string): Promise<FlowIpMeta | null> {
ok: Boolean(asn || country),
fetchedAt: Date.now(),
}
mem.set(prefix, entry)
remember(entry)
persist(entry)
return entry
} catch {
const prefix = `${ip}/32`
const entry = negative(prefix)
mem.set(prefix, entry)
remember(entry)
persist(entry)
return entry
} finally {
@@ -53,6 +53,7 @@ export function toTrafficFlowSettingsDto(
hubServerId: row.hubServerId ?? null,
retentionHours: row.retentionHours,
topN: row.topN,
mapServiceMinSharePct: Number(row.mapServiceMinSharePct ?? 5),
lastDatagramAt: row.lastDatagramAt ?? null,
lastExporterIp: row.lastExporterIp ?? null,
lastError: row.lastError || null,
@@ -75,6 +76,9 @@ export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
retentionHours: patch.retentionHours ?? row.retentionHours,
topN: patch.topN ?? row.topN,
mapServiceMinSharePct: patch.mapServiceMinSharePct == null
? row.mapServiceMinSharePct
: Math.min(100, Math.max(0, patch.mapServiceMinSharePct)),
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
return getTrafficFlowSettingsRow()
@@ -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":
@@ -47,6 +47,8 @@ function NetflowSettingsPanel({
const [endpoint, setEndpoint] = useState("")
const [retention, setRetention] = useState("24")
const [topN, setTopN] = useState("200")
const [shareOn, setShareOn] = useState(true)
const [sharePct, setSharePct] = useState("5")
const [ingestOn, setIngestOn] = useState(false)
const [purgeOpen, setPurgeOpen] = useState(false)
const [purgeBusy, setPurgeBusy] = useState(false)
@@ -62,6 +64,9 @@ function NetflowSettingsPanel({
setEndpoint(s.publicEndpoint)
setRetention(String(s.retentionHours))
setTopN(String(s.topN))
const pct = Number(s.mapServiceMinSharePct ?? 5)
setShareOn(pct > 0)
setSharePct(String(pct > 0 ? pct : 5))
setIngestOn(s.enabled)
}, [backendUrl, enabled])
@@ -83,6 +88,9 @@ function NetflowSettingsPanel({
publicEndpoint: endpoint,
retentionHours: Number.parseInt(retention, 10) || 24,
topN: Number.parseInt(topN, 10) || 200,
mapServiceMinSharePct: shareOn
? Math.min(100, Math.max(1, Number.parseFloat(sharePct) || 5))
: 0,
})
setSettings(res.settings)
toast.success("Настройки NetFlow сохранены")
@@ -193,6 +201,29 @@ function NetflowSettingsPanel({
<FormField label="Top-N разговоров">
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
</FormField>
<div className="sm:col-span-2 flex flex-col gap-2">
<div className="flex items-center gap-3">
<FormToggle
checked={shareOn}
onChange={(on) => {
setShareOn(on)
if (on && (!sharePct || sharePct === "0")) setSharePct("5")
}}
/>
<span className="text-sm">Порог доли на карте</span>
</div>
<FormField
label="Минимум % окна"
hint="Узел сервиса, если доля байт окна ≥ N%. Выключить — показать все распознанные бренды (макс. 20)"
>
<Input
value={sharePct}
onChange={(e) => setSharePct(e.target.value)}
inputMode="decimal"
disabled={!shareOn}
/>
</FormField>
</div>
</div>
<p className="text-xs text-muted-foreground">
+43
View File
@@ -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 }
}
+15
View File
@@ -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"
}
}
}
}
+23
View File
@@ -21,6 +21,7 @@ export const trafficFlowSettingsDtoSchema = z.object({
hubServerId: z.number().int().positive().nullable(),
retentionHours: z.number().int().positive(),
topN: z.number().int().positive(),
mapServiceMinSharePct: z.number().min(0).max(100),
lastDatagramAt: z.string().nullable(),
lastExporterIp: z.string().nullable(),
lastError: z.string().nullable(),
@@ -40,6 +41,7 @@ export const trafficFlowSettingsPatchSchema = z.object({
hubServerId: z.number().int().positive().nullable().optional(),
retentionHours: z.number().int().positive().optional(),
topN: z.number().int().positive().max(1000).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
})
export const trafficFlowOverlayRequestSchema = z.object({
@@ -279,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({
@@ -289,6 +309,8 @@ 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(),
excludeOverlayApplied: z.boolean(),
@@ -310,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>
+1 -1
View File
File diff suppressed because one or more lines are too long