Compare commits

...
5 Commits
Author SHA1 Message Date
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
DenozordecandCursor db64621122 feat(network-map): показать конечные сервисы на карте сети
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 2m0s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 12s
NetFlow ≥ 5% окна, узлы с логотипом бренда справа от EN, скорость потока на рёбрах к сервисам.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 13:30:45 +07:00
23 changed files with 1447 additions and 58 deletions
+427 -19
View File
@@ -17,11 +17,14 @@ import {
buildServerResourceMap,
buildWanJhEdges,
computeNetworkMapLayout,
NETWORK_MAP_H,
NETWORK_MAP_LAYOUT_REVISION,
NETWORK_MAP_PIPELINE_Y,
NETWORK_MAP_W,
findServerByGreRemote,
greSourceWanIndexOnMap,
greTunnelProbe,
placeServiceNodes,
type GreMapEdge,
type WanJhEdge,
} from "@/lib/network-map-layout"
@@ -49,7 +52,8 @@ import {
matchNetflowForWan,
type MatchedNetflowHop,
} from "@/lib/map-netflow-hops"
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } 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"
import { StatusDot } from "@/components/status-dot"
@@ -235,8 +239,8 @@ function Sparkline({ history }: { history: number[] }) {
// ─── Canvas dimensions ────────────────────────────────────────────────────────
const W = 1060
const H = 580
const W = NETWORK_MAP_W
const H = NETWORK_MAP_H
const ZOOM_MIN = 0.2
const ZOOM_MAX = 6
@@ -271,6 +275,103 @@ const TYPE_LABELS: Record<ServerType, string> = {
"home-router": "Home Router",
}
const MOCK_MAP_SERVICES: FlowMapService[] = [
{ id: "svc:google", label: "Google", category: "Веб", bytes: 22_000_000, bps: 8_800_000, share: 0.22 },
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 14_000_000, bps: 5_600_000, share: 0.14 },
{ id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
]
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, 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" }] },
]
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) {
@@ -608,6 +709,74 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
)
}
function ServiceNode({
label,
share,
x,
y,
isSel,
isVis,
isDragged,
onClick,
onMouseDown,
}: {
label: string
share: number
x: number
y: number
isSel: boolean
isVis: boolean
isDragged: boolean
onClick: () => void
onMouseDown: (e: React.MouseEvent) => void
}) {
const bw = 86
const bh = 58
return (
<g
transform={`translate(${x},${y})`}
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>
{isSel && (
<rect
x={-bw / 2 - 6}
y={-bh / 2 - 6}
width={bw + 12}
height={bh + 12}
rx="12"
fill="none"
stroke="rgba(56,189,248,0.55)"
strokeWidth="1.5"
strokeDasharray="4 3"
/>
)}
<rect
x={-bw / 2}
y={-bh / 2}
width={bw}
height={bh}
rx="10"
fill="#08202c"
stroke="#22d3ee"
strokeWidth={isSel ? 2.2 : 1.4}
/>
<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>
<text textAnchor="middle" y="25" fontSize="7.5" fill="#67e8f9" fontFamily="ui-monospace,monospace">
{serviceSharePct(share)}
</text>
</g>
)
}
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
x: number; y: number
wan: { name: string; isp: string; maxDl: number; maxUl: number }
@@ -723,13 +892,14 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void })
const MM_W = 172, MM_H = 94
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, onClose, onPan }: {
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
pan: { x: number; y: number }; zoom: number
nodes: (Server & { x: number; y: number })[]
greEdges: GreMapEdge[]
satPos: Record<string, { x: number; y: number }[]>
wanJhEdges: WanJhEdge[]
homeRouters: Server[]
servicePos: Record<string, { x: number; y: number }>
onClose: () => void
onPan: (x: number, y: number) => void
}) {
@@ -782,6 +952,10 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
fill={WAN_COLORS[i] + "33"} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" />
))
)}
{Object.entries(servicePos).map(([id, p]) => (
<rect key={id} x={p.x - 14} y={p.y - 10} width="28" height="20" rx="4"
fill="#08202c" stroke="#22d3ee" strokeWidth="3" opacity="0.85" />
))}
{/* viewport rect */}
<rect x={pan.x} y={pan.y} width={W / zoom} height={H / zoom}
fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.6)" strokeWidth="6" rx="6" />
@@ -841,6 +1015,9 @@ export default function NetworkMapPage() {
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
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)
@@ -932,6 +1109,10 @@ export default function NetworkMapPage() {
setMapGreTunnels(mockGreTunnels)
setSpeedProbes([])
setGreResolvedIpv4ByHost({})
setMapHops([])
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapSharePct(5)
setDataError(null)
})
return
@@ -959,12 +1140,14 @@ export default function NetworkMapPage() {
// ── Interaction ─────────────────────────────────────────────────────────────
const [selected, setSelected] = useState<Server | null>(null)
const [selectedService, setSelectedService] = useState<FlowMapService | 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(() => {
@@ -976,6 +1159,7 @@ export default function NetworkMapPage() {
// и перекрывают новый авто-лейаут на live-данных.
setNodePositions({})
setSatPositions({})
setServicePositions({})
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
}
} catch {
@@ -993,29 +1177,57 @@ export default function NetworkMapPage() {
const [search, setSearch] = useState("")
const [showPingBadges, setShowPingBadges] = useState(true)
const [showNetflow, setShowNetflow] = useState(true)
const [showServices, setShowServices] = useState(true)
const [showAnimDots, setShowAnimDots] = useState(true)
const [showMinimap, setShowMinimap] = useState(true)
const [showHints, setShowHints] = useState(false)
const [showLayers, setShowLayers] = useState(false)
useEffect(() => {
if (!useLiveData || !showNetflow) {
queueMicrotask(() => setMapHops([]))
if (!useLiveData) {
queueMicrotask(() => {
setMapHops([])
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapSharePct(5)
})
return
}
if (!showNetflow && !showServices) {
queueMicrotask(() => {
setMapHops([])
setMapServices([])
setMapServiceEdges([])
})
return
}
let cancelled = false
let ac: AbortController | null = null
const tick = () => {
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m")
.then((res) => { if (!cancelled) setMapHops(res.hops ?? []) })
.catch(() => { if (!cancelled) setMapHops([]) })
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 ?? [])
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
})
.catch((err: unknown) => {
if (cancelled) return
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, apiFetch])
}, [useLiveData, showNetflow, showServices, apiFetch])
const effectiveSatPos = useMemo(() => {
const out: Record<string, { x: number; y: number }[]> = {}
@@ -1203,6 +1415,11 @@ export default function NetworkMapPage() {
return m
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
const visibleMapServices = showServices ? mapServices : []
const visibleServiceEdges = showServices
? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById)
: []
const nodes = mapServers
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
// Визуальный приоритет: HR поверх JH, JH поверх EN.
@@ -1215,6 +1432,17 @@ export default function NetworkMapPage() {
})
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
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)
@@ -1229,10 +1457,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)
@@ -1289,7 +1519,7 @@ 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) }
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(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()
@@ -1342,6 +1572,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] ?? []))]
@@ -1369,6 +1601,7 @@ export default function NetworkMapPage() {
nodeDragRef.current = null
setDraggedNodeId(null)
setDraggedSatKey(null)
setDraggedSvcId(null)
return true
}
@@ -1377,7 +1610,7 @@ export default function NetworkMapPage() {
const moved = dragRef.current?.moved ?? false
dragRef.current = null
setIsDragging(false)
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
}
// ── Node drag start ──────────────────────────────────────────────────────
@@ -1389,6 +1622,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()
@@ -1409,12 +1646,20 @@ export default function NetworkMapPage() {
// ── Side panel ────────────────────────────────────────────────────────────
function selectServer(s: Server) {
setSelectedGreEdge(null)
setSelectedService(null)
setSelected(prev => prev?.id === s.id ? null : s)
setSelWanIdx(null)
setHoveredId(null)
}
function selectService(svc: FlowMapService) {
setSelectedGreEdge(null)
setSelected(null)
setSelWanIdx(null)
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
}
function selectWan(s: Server, wanIdx: number) {
setSelectedGreEdge(null)
setSelectedService(null)
setSelected(s)
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
}
@@ -1424,6 +1669,7 @@ export default function NetworkMapPage() {
const home = mapServers.find((s) => s.id === edge.homeId)
if (!home) return
setSelectedGreEdge(null)
setSelectedService(null)
setSelected(home)
setSelWanIdx(edge.wanIdx)
setHoveredId(null)
@@ -1565,6 +1811,7 @@ export default function NetworkMapPage() {
{([
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
{ key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" },
{ key: "showServices", label: "Сервисы", val: showServices, set: setShowServices, hint: "" },
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
@@ -1589,10 +1836,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">
Сбросить расположение
@@ -1705,6 +1957,7 @@ export default function NetworkMapPage() {
ev.stopPropagation()
setSelectedGreEdge(e)
setSelected(null)
setSelectedService(null)
setSelWanIdx(null)
}
return (
@@ -1891,6 +2144,78 @@ export default function NetworkMapPage() {
}).filter(Boolean)
})}
{visibleMapServices.map((svc) => {
const pos = servicePosById[svc.id]
if (!pos) return null
return (
<ServiceNode
key={svc.id}
label={svc.label}
share={svc.share}
x={pos.x}
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)
}}
/>
)
})}
{/* ── EN → destination services (поверх узлов, чтобы пунктир не прятался) ── */}
{visibleServiceEdges.map((edge) => {
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
const to = servicePosById[edge.toId]
if (!from || !to) return null
const hop: MatchedNetflowHop = {
bytes: edge.bytes,
bps: edge.bps,
bpsFwd: edge.bpsFwd,
bpsRev: edge.bpsRev,
}
const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId
const svc = visibleMapServices.find((s) => s.id === edge.toId)
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel}${enName}${svc?.label ?? edge.toId}`
return (
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
<title>{pathTitle}</title>
<line
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
stroke="#22d3ee"
strokeWidth={hopHasRate(hop) ? 2.4 : 1.4}
strokeDasharray="6 5"
opacity="0.85"
pointerEvents="none"
/>
{showAnimDots && hopHasRate(hop) && (
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
<animateMotion dur="2.6s" repeatCount="indefinite"
path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
</circle>
)}
{hopHasRate(hop) && (
<NetflowRateBadge
mx={mx}
my={my}
hop={hop}
onOpen={(ev) => {
ev.stopPropagation()
const svc = visibleMapServices.find((s) => s.id === edge.toId)
if (svc) selectService(svc)
}}
/>
)}
</g>
)
})}
{/* ── Hover tooltip ── */}
{hoveredNode && !isDragging && (
<SvgTooltip n={hoveredNode} />
@@ -1898,7 +2223,7 @@ export default function NetworkMapPage() {
{/* ── Legend (viewport-fixed) ── */}
<g transform={`translate(${pan.x + 14}, ${pan.y + 14})`}>
<rect width="140" height="224" rx="8"
<rect width="140" height="250" rx="8"
fill="rgba(6,13,26,0.88)" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<text x="10" y="22" fontSize="8" fontWeight="700" fill="#64748b"
fontFamily="system-ui" letterSpacing="0.08em">ЛЕГЕНДА</text>
@@ -1927,12 +2252,17 @@ export default function NetworkMapPage() {
</g>
))}
<line x1="10" y1="166" x2="130" y2="166" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<g transform="translate(10, 164)">
<rect width="14" height="14" rx="4" fill="#08202c" stroke="#22d3ee" strokeWidth="1.2" />
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">Сервис</text>
</g>
<text x="10" y="180" fontSize="7.5" fontWeight="700" fill="#475569"
<line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<text x="10" y="200" fontSize="7.5" fontWeight="700" fill="#475569"
fontFamily="system-ui" letterSpacing="0.05em">WAN АПЛИНКИ</text>
{WAN_COLORS.slice(0, 2).map((c, i) => (
<g key={i} transform={`translate(10, ${190 + i * 14})`}>
<g key={i} transform={`translate(10, ${210 + i * 14})`}>
<circle cx="5" cy="4" r="4" fill={c} opacity="0.9" />
<text x="16" y="8" fontSize="8" fill="#94a3b8" fontFamily="ui-monospace,monospace">
WAN{i + 1}
@@ -1988,6 +2318,7 @@ export default function NetworkMapPage() {
satPos={effectiveSatPos}
wanJhEdges={visibleWanJhEdges}
homeRouters={homeRouters}
servicePos={servicePosById}
onClose={() => setShowMinimap(false)}
onPan={(x, y) => setPan({ x, y })}
/>
@@ -2016,7 +2347,7 @@ export default function NetworkMapPage() {
</div>
{/* ── Side panel (узел или выбранное GRE-ребро) ── */}
{(selectedGreEdge || selected) && (
{(selectedGreEdge || selected || selectedService) && (
<div className="border-l flex flex-col overflow-hidden shrink-0 bg-background" style={{ width: 300 }}>
{selectedGreEdge ? (
<>
@@ -2222,6 +2553,83 @@ export default function NetworkMapPage() {
})()}
</div>
</>
) : selectedService ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
<ServiceBrandIcon label={selectedService.label} size={22} />
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm truncate">{selectedService.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Конечный сервис · {selectedService.category}
</p>
</div>
<button
type="button"
onClick={() => setSelectedService(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля окна</span>
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(selectedService.share)}</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium">
{formatNetflowRate({
bytes: selectedService.bytes,
bps: selectedService.bps,
bpsFwd: selectedService.bps,
bpsRev: 0,
})}
</span>
</div>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
<div className="flex flex-col gap-1.5">
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
const src = mapServers.find((s) => s.id === e.fromId)
return (
<div key={`${e.fromId}|${e.toId}`} className="flex items-center justify-between text-xs">
<span className="font-mono truncate">{src?.name ?? e.fromId}</span>
<span className="font-mono text-emerald-400 tabular-nums">
{formatNetflowRate({ bytes: e.bytes, bps: e.bps, bpsFwd: e.bpsFwd, bpsRev: e.bpsRev })}
</span>
</div>
)
})}
</div>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Клиенты</p>
<div className="flex flex-col gap-1.5">
{(() => {
const names = new Map<string, string>()
for (const e of visibleServiceEdges.filter((x) => x.toId === selectedService.id)) {
if (e.clients?.length) {
for (const c of e.clients) names.set(c.id, c.name)
} else if (e.clientName) {
names.set(e.clientId ?? e.clientName, e.clientName)
}
}
if (names.size === 0) {
return <p className="text-xs text-muted-foreground"></p>
}
return [...names.values()].map((name) => (
<p key={name} className="text-xs font-mono truncate">{name}</p>
))
})()}
</div>
</div>
</div>
</>
) : selected ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
+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) => {
@@ -4,6 +4,8 @@ import {
countryFromHolder,
lookupBrand,
OTHER_SERVICE,
isNamedInternetService,
mapServiceNodeId,
resolveRipeCountry,
} from "./traffic-flow-brands.js"
@@ -21,9 +23,20 @@ assert.equal(brandByAsn(15169)?.category, "Веб")
assert.equal(lookupBrand("208.65.153.1", 0)?.service, "YouTube")
assert.equal(brandByAsn(32590)?.service, "Steam")
assert.equal(brandByAsn(32590)?.category, "Игры")
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)
assert.equal(isNamedInternetService("Прочее", "Прочее"), false)
assert.equal(isNamedInternetService("GRE", "Туннель"), false)
assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
console.log("traffic-flow-brands.test.ts: ok")
+49 -8
View File
@@ -12,11 +12,12 @@ const ASN_BRANDS = new Map<number, BrandHit>([
[209242, { service: "Cloudflare", category: "CDN" }],
[54113, { service: "Fastly", category: "CDN" }],
[20940, { service: "Akamai", category: "CDN" }],
[16509, { service: "Amazon", category: "CDN" }],
[14618, { service: "Amazon", category: "CDN" }],
[16509, { service: "AWS", category: "CDN" }],
[14618, { service: "AWS", category: "CDN" }],
[8075, { service: "Microsoft", category: "CDN" }],
[13238, { service: "Yandex", category: "CDN" }],
[32590, { service: "Steam", category: "Игры" }],
[57976, { service: "Blizzard", category: "Игры" }],
[2906, { service: "Netflix", category: "Видео / стриминг" }],
[40027, { service: "Netflix", category: "Видео / стриминг" }],
[15169, { service: "Google", category: "Веб" }],
@@ -41,6 +42,7 @@ const ASN_HQ_COUNTRY = new Map<number, string>([
[8075, "US"],
[15169, "US"],
[32590, "US"],
[57976, "US"],
[2906, "US"],
[40027, "US"],
[36040, "US"],
@@ -54,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"])
@@ -105,3 +117,32 @@ export function brandByCidr(ip: string): BrandHit | null {
export function lookupBrand(ip: string, asn: number): BrandHit | null {
return brandByCidr(ip) || brandByAsn(asn)
}
const SKIP_MAP_SERVICES = new Set([
OTHER_SERVICE,
"GRE",
"ESP",
"WireGuard",
"DNS",
"SSH",
"BGP",
])
const SKIP_MAP_CATEGORIES = new Set(["Туннель", "DNS", "SSH", "BGP"])
/** Именованный интернет-сервис для карты (не туннель и не «Прочее»). */
export function isNamedInternetService(service: string, category: string): boolean {
const s = service.trim()
const c = category.trim()
if (!s || SKIP_MAP_SERVICES.has(s) || SKIP_MAP_CATEGORIES.has(c)) return false
return true
}
export function mapServiceNodeId(label: string): string {
const slug = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
return `svc:${slug || "unknown"}`
}
@@ -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,13 +4,14 @@ 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 {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
disableCatalogFetchForTests()
@@ -113,6 +114,7 @@ ingestParsedFlowsForServerForTests(3, [
])
try {
resetFlowMapHopsCacheForTests()
const def = buildFlowMapHops({ minutes: 5 })
assert.equal(def.excludeOverlayApplied, true)
assert.equal(def.excludeMeshApplied, true)
@@ -141,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)
@@ -154,4 +157,256 @@ try {
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: hops ok")
function googleRipe() {
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: 37.4,
lng: -122.1,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
}
function payloadFlow(dst: string, bytes: number) {
return {
src: "10.100.1.17",
dst,
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes,
packets: Math.max(1, Math.round(bytes / 1200)),
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
}
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 600),
payloadFlow("203.0.113.50", 9400),
])
try {
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)
const googleEdge = six.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
assert.ok(googleEdge)
assert.equal(googleEdge.clientName, "Alice")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 400),
payloadFlow("203.0.113.50", 9600),
])
try {
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()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "203.0.113.10",
dst: "198.51.100.1",
proto: 47,
srcPort: 0,
dstPort: 0,
bytes: 9_000,
packets: 90,
inIface: "3",
outIface: "3",
},
payloadFlow("203.0.113.50", 1000),
])
try {
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)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
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")
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: ok")
+232 -6
View File
@@ -1,13 +1,25 @@
import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } 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 { 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 { 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 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
@@ -17,6 +29,8 @@ export interface FlowMapHopsQuery {
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
/** Переопределение порога (тесты). Иначе из настроек NetFlow. */
minSharePct?: number
}
interface HopAcc {
@@ -31,6 +45,56 @@ interface HopAcc {
bytesRev: number
}
interface FromAcc {
bytes: number
clients: Map<string, string>
}
interface DstAcc {
bytes: number
proto: number
dstPort: number
srcPort: number
fromBytes: Map<string, FromAcc>
}
function bumpFrom(acc: DstAcc, exporterId: string, bytes: number, client: { userId: string; name: string } | null): void {
const prev = acc.fromBytes.get(exporterId)
if (prev) {
prev.bytes += bytes
if (client) prev.clients.set(client.userId, client.name)
return
}
const clients = new Map<string, string>()
if (client) clients.set(client.userId, client.name)
acc.fromBytes.set(exporterId, { bytes, clients })
}
let hopsCache: { key: string; at: number; dto: FlowMapHopsDto } | null = null
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()
@@ -81,8 +145,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
@@ -114,6 +206,10 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
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) {
const inRes = resolveIfaceName(r.serverId, r.inIface)
@@ -164,6 +260,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)
@@ -199,18 +296,147 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
}, r.bytes, "fwd")
}
}
totalBytes += 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 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>
}>()
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
}
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)
if (prevEdge) {
prevEdge.bytes += from.bytes
prevEdge.bytesFwd += from.bytes
for (const [id, name] of from.clients) prevEdge.clients.set(id, name)
} else {
svcEdges.set(edgeKey, {
fromId,
toId,
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: new Map(from.clients),
})
}
}
}
const minShare = minSharePct / 100
let services: FlowMapService[] = [...svcTotals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
}))
.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) => {
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 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,
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()
@@ -0,0 +1,131 @@
"use client"
import type { ReactNode } from "react"
function slug(label: string): string {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
}
function GenericCloud({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
<path
d="M7.5 18h9.2A4.3 4.3 0 0 0 21 13.8a4.2 4.2 0 0 0-3.7-4.2A6.1 6.1 0 0 0 6.2 11 3.8 3.8 0 0 0 3 14.7 3.7 3.7 0 0 0 6.8 18Z"
fill="#38bdf8"
opacity="0.92"
/>
</svg>
)
}
function BrandSvg({ children, size }: { children: ReactNode; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
{children}
</svg>
)
}
export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: number }) {
switch (slug(label)) {
case "cloudflare":
return (
<BrandSvg size={size}>
<path d="M6.2 15.4h12.4c1.6 0 2.6-1.1 2.4-2.4-.2-1.4-1.4-2.1-2.8-2.1-.3-2.4-2.3-4.1-4.8-4.1-1.9 0-3.5 1-4.4 2.5-.4-.2-.9-.3-1.4-.3-1.7 0-3.1 1.3-3.2 3-.1 1.8 1.3 3.4 3.2 3.4Z" fill="#F38020" />
</BrandSvg>
)
case "google":
return (
<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":
return (
<BrandSvg size={size}>
<path d="M6.2 8.2 12 5.4l5.8 2.8v3.4L12 14.6 6.2 11.6Z" fill="#232F3E" />
<path d="M5.2 15.6c3.6 2.6 9.8 2.7 13.6 0" fill="none" stroke="#FF9900" strokeWidth="1.8" strokeLinecap="round" />
</BrandSvg>
)
case "steam":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#1b2838" />
<circle cx="8.2" cy="14.4" r="3.1" fill="#66c0f4" />
<circle cx="15.4" cy="9.2" r="3.6" fill="#c7d5e0" />
<circle cx="15.4" cy="9.2" r="1.5" fill="#1b2838" />
</BrandSvg>
)
case "blizzard":
return (
<BrandSvg size={size}>
<path d="M6 5h7.4c3 0 4.8 1.6 4.8 4.1 0 1.8-1 3.1-2.6 3.7 2 .5 3.2 2 3.2 4.1 0 2.8-2.1 4.6-5.6 4.6H6Z" fill="#00AEFF" />
<path d="M9.2 8.2h3.4c1.2 0 1.8.6 1.8 1.5s-.6 1.5-1.8 1.5H9.2Zm0 5.2h3.8c1.3 0 2 .6 2 1.6s-.7 1.6-2 1.6H9.2Z" fill="#06121f" />
</BrandSvg>
)
case "youtube":
return (
<BrandSvg size={size}>
<rect x="2" y="6" width="20" height="12" rx="3" fill="#FF0000" />
<path d="M10.2 9.2v5.6L15.6 12Z" fill="#fff" />
</BrandSvg>
)
case "netflix":
return (
<BrandSvg size={size}>
<path d="M6 3h3.2l5.6 18H11.6Z" fill="#E50914" />
<path d="M14.8 3H18v18h-3.2Z" fill="#B81D24" />
</BrandSvg>
)
case "microsoft":
return (
<BrandSvg size={size}>
<rect x="3" y="3" width="8" height="8" fill="#F25022" />
<rect x="13" y="3" width="8" height="8" fill="#7FBA00" />
<rect x="3" y="13" width="8" height="8" fill="#00A4EF" />
<rect x="13" y="13" width="8" height="8" fill="#FFB900" />
</BrandSvg>
)
case "meta":
return (
<BrandSvg size={size}>
<path d="M4 14.5c1.8-4.2 4-7.5 6.4-7.5 1.6 0 2.5 1.3 4.6 6.3 1.4 3.4 2.2 4.7 3.4 4.7 1.8 0 3.6-2.6 4.6-5" fill="none" stroke="#0081FB" strokeWidth="2.2" strokeLinecap="round" />
</BrandSvg>
)
case "telegram":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#229ED9" />
<path d="M7.2 12.1 16.8 8.4 15 16.2l-3.1-1.8-1.6 1.6-.2-2.6Z" fill="#fff" />
</BrandSvg>
)
case "discord":
return (
<BrandSvg size={size}>
<path d="M7.2 5.8 8.6 4.6c2.1.8 4.2 1.2 6.4 1.2h.8L17 5.8c1.8 2.4 2.6 5.4 2.4 8.6-1.6 1.2-3.3 2.1-5.2 2.6L13 15.2c.7-.2 1.3-.6 1.8-1.1-2 .9-4.2.9-6.2 0 .5.5 1.1.9 1.8 1.1L8.8 17c-1.9-.5-3.6-1.4-5.2-2.6C3.4 11.2 4.2 8.2 6 5.8Z" fill="#5865F2" />
<circle cx="9.2" cy="11.2" r="1.2" fill="#fff" />
<circle cx="14.8" cy="11.2" r="1.2" fill="#fff" />
</BrandSvg>
)
case "twitch":
return (
<BrandSvg size={size}>
<path d="M5 4h14v10.2l-4 4H11l-2.2 2.2H7.2V18.2H5Z" fill="#9146FF" />
<path d="M7.4 6.4h1.8v5.2H7.4Zm4 0h1.8v5.2H11.4Z" fill="#fff" />
</BrandSvg>
)
case "tiktok":
return (
<BrandSvg size={size}>
<path d="M14.2 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H14.2Z" fill="#25F4EE" />
<path d="M13.4 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H13.4Z" fill="#FE2C55" transform="translate(1.2 1)" />
</BrandSvg>
)
default:
return <GenericCloud size={size} />
}
}
@@ -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">
+28 -3
View File
@@ -35,9 +35,10 @@ export function greTunnelProbe(t: GreTunnel): TunnelProbe {
}
}
const W = 1060
const W = 1240
const H = 580
const MARGIN = 72
const SERVICE_COL_W = 150
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
export const NETWORK_MAP_PIPELINE_Y = 300
@@ -68,7 +69,9 @@ function layerOfServer(s: Server): number | null {
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
*/
export const NETWORK_MAP_LAYOUT_REVISION = 6
export const NETWORK_MAP_W = W
export const NETWORK_MAP_H = H
export const NETWORK_MAP_LAYOUT_REVISION = 7
export interface WanJhEdge {
homeId: string
@@ -254,7 +257,7 @@ export function computeNetworkMapLayout(
const nodePos: Record<string, { x: number; y: number }> = {}
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
const span = W - 2 * MARGIN
const span = W - 2 * MARGIN - SERVICE_COL_W
const laneGap = Math.min(44, span * 0.04)
const laneW = (span - 2 * laneGap) / 3
@@ -425,6 +428,28 @@ export function computeNetworkMapLayout(
return { nodePos, wanSatPos }
}
/** Колонка конечных сервисов справа от EN. */
export function placeServiceNodes(
serviceIds: string[],
enPositions: Array<{ x: number; y: number }>,
): Record<string, { x: number; y: number }> {
const out: Record<string, { x: number; y: number }> = {}
if (serviceIds.length === 0) return out
const minY = MARGIN + 70
const maxY = H - 72
const x = W - MARGIN - SERVICE_COL_W / 2
const enYs = enPositions.map((p) => p.y).filter((y) => Number.isFinite(y))
const centerY = enYs.length ? enYs.reduce((a, b) => a + b, 0) / enYs.length : (minY + maxY) / 2
const n = serviceIds.length
const gap = Math.min(96, (maxY - minY) / Math.max(1, n))
const span = gap * (n - 1)
const start = clamp(centerY - span / 2, minY, maxY - span)
serviceIds.forEach((id, i) => {
out[id] = { x, y: n === 1 ? clamp(centerY, minY, maxY) : start + i * gap }
})
return out
}
/**
* Суммарная задержка «дом JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
* Отдельного ICMP по ребру нет это не замер линии, а сумма каталожных latency концов.
+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"
}
}
}
}
+32
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({
@@ -263,11 +265,39 @@ export const flowMapHopDtoSchema = z.object({
bpsRev: z.number().nonnegative(),
})
export const flowMapServiceDtoSchema = z.object({
id: z.string(),
label: z.string(),
category: z.string(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
share: z.number().min(0).max(1),
})
export const flowMapServiceEdgeDtoSchema = z.object({
fromId: z.string(),
toId: z.string(),
bytes: z.number().nonnegative(),
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 flowMapHopsDtoSchema = z.object({
hops: z.array(flowMapHopDtoSchema),
live: z.boolean(),
rangeMinutes: z.number().int().positive(),
windowSec: z.number().positive(),
totalBytes: z.number().nonnegative().optional(),
services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(),
excludeOverlayApplied: z.boolean(),
@@ -287,4 +317,6 @@ export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema>
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 FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
+1 -1
View File
File diff suppressed because one or more lines are too long