diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 6883a03..fa4d7a9 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -41,6 +41,15 @@ import { wanJhEdgeMapKey, type GreSpeedProbeSnapshot, } from "@/lib/map-gre-speed-probe" +import { + formatNetflowDir, + formatNetflowRate, + hopHasRate, + matchNetflowForGreEdge, + matchNetflowForWan, + type MatchedNetflowHop, +} from "@/lib/map-netflow-hops" +import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow" import { Button } from "@/components/ui/button" import { StatusBadge } from "@/components/status-badge" import { StatusDot } from "@/components/status-dot" @@ -420,6 +429,53 @@ function GreEdgeMetricBadge({ ) } +/** Живой поток NetFlow (не ёмкость канала / не BT). */ +function NetflowRateBadge({ + mx, + my, + hop, + onOpen, +}: { + mx: number + my: number + hop: MatchedNetflowHop + onOpen?: (e: React.MouseEvent) => void +}) { + const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0 + const bw = showDir ? 86 : 72 + const bh = showDir ? 32 : 20 + return ( + { e.stopPropagation() }} + onClick={(e) => { e.stopPropagation(); onOpen?.(e) }} + > + + Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно. + + + + {formatNetflowRate(hop)} + + {showDir && ( + + {formatNetflowDir(hop)} + + )} + + ) +} + function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) { const ss = STATUS_STYLE[n.status] const ts = TYPE_STYLE[n.type] @@ -784,6 +840,7 @@ export default function NetworkMapPage() { const [mapServers, setMapServers] = useState([]) const [mapGreTunnels, setMapGreTunnels] = useState([]) const [speedProbes, setSpeedProbes] = useState([]) + const [mapHops, setMapHops] = useState([]) /** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */ const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState>({}) const [dataError, setDataError] = useState(null) @@ -935,11 +992,31 @@ export default function NetworkMapPage() { const [filter, setFilter] = useState("all") const [search, setSearch] = useState("") const [showPingBadges, setShowPingBadges] = useState(true) + const [showNetflow, setShowNetflow] = 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([])) + return + } + let cancelled = false + const tick = () => { + apiFetch("/api/traffic/flow/map-hops?range=5m") + .then((res) => { if (!cancelled) setMapHops(res.hops ?? []) }) + .catch(() => { if (!cancelled) setMapHops([]) }) + } + tick() + const id = window.setInterval(tick, 4000) + return () => { + cancelled = true + window.clearInterval(id) + } + }, [useLiveData, showNetflow, apiFetch]) + const effectiveSatPos = useMemo(() => { const out: Record = {} mapServers @@ -1100,6 +1177,32 @@ export default function NetworkMapPage() { return out }, [greEdges]) + const netflowByGreKey = useMemo(() => { + const m = new Map() + if (!showNetflow) return m + for (const e of greEdges) { + const hop = matchNetflowForGreEdge(e, mapHops) + if (hopHasRate(hop)) m.set(greEdgeKey(e), hop) + } + return m + }, [greEdges, mapHops, showNetflow]) + + const netflowByWanKey = useMemo(() => { + const m = new Map() + if (!showNetflow) return m + for (const home of homeRouters) { + for (const [wIdx, wan] of (home.wanUplinks ?? []).entries()) { + const hop = matchNetflowForWan(home.id, wan.iface, mapHops) + if (!hopHasRate(hop)) continue + m.set(`${home.id}\t${wIdx}`, hop) + for (const e of wanJhEdges) { + if (e.homeId === home.id && e.wanIdx === wIdx) m.set(wanJhEdgeMapKey(e), hop) + } + } + } + return m + }, [homeRouters, wanJhEdges, mapHops, showNetflow]) + const nodes = mapServers .map((s) => ({ ...s, ...nodePosById[s.id]! })) // Визуальный приоритет: HR поверх JH, JH поверх EN. @@ -1461,6 +1564,7 @@ export default function NetworkMapPage() { onMouseLeave={() => setShowLayers(false)}> {([ { key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" }, + { key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, 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: "" }, @@ -1588,6 +1692,15 @@ export default function NetworkMapPage() { tBadge, normalPx, ) + const flowHop = netflowByGreKey.get(edgeId) + const flowPos = edgeBadgePosition( + e.from.x, + e.from.y, + e.to.x, + e.to.y, + tBadge, + -normalPx - (normalPx === 0 ? 22 : 0), + ) function openGreDetail(ev: React.MouseEvent) { ev.stopPropagation() setSelectedGreEdge(e) @@ -1598,7 +1711,7 @@ export default function NetworkMapPage() { @@ -1632,6 +1745,14 @@ export default function NetworkMapPage() { outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)} /> )} + {showNetflow && hopHasRate(flowHop) && ( + + )} ) })} @@ -1663,14 +1784,16 @@ export default function NetworkMapPage() { const color = WAN_COLORS[edge.wanIdx] ?? "#888" const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online" const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17) + const flowPos = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.38, 18) const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx) + const wanFlow = netflowByWanKey.get(wanJhEdgeMapKey(edge)) return ( })()} + {showNetflow && hopHasRate(wanFlow) && ( + openWanJhSpeedDetail(ev, edge)} + /> + )} ) })} @@ -1976,7 +2107,7 @@ export default function NetworkMapPage() {
- {bwMon ? "TX / RX (BT)" : "Скорость (модель)"} + {bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"} {merged.dlMbps != null && merged.ulMbps != null @@ -1984,10 +2115,23 @@ export default function NetworkMapPage() { : "—"}
+
+ Поток (NetFlow) + + {(() => { + const hop = netflowByGreKey.get(selectedEdgeId) + if (!hopHasRate(hop)) return "—" + return hop.bpsFwd > 0 && hop.bpsRev > 0 + ? formatNetflowDir(hop) + : formatNetflowRate(hop) + })()} + +

{merged.hasSpeedMonitor - ? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам." - : "«Модель RTT» и «скорость» — демо до появления подходящей speed-пробы в «Мониторинг → скорость»."} + ? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. " + : "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "} + Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.

@@ -2168,12 +2312,26 @@ export default function NetworkMapPage() {
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip], - ["BW", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => ( + ["Канал", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
{k} {v}
))} + {(() => { + const hop = netflowByWanKey.get(`${selected.id}\t${wIdx}`) + if (!hopHasRate(hop)) return null + return ( +
+ Поток + + {hop.bpsFwd > 0 && hop.bpsRev > 0 + ? formatNetflowDir(hop) + : formatNetflowRate(hop)} + +
+ ) + })()}
{myEdges.length > 0 && (
@@ -2230,9 +2388,11 @@ export default function NetworkMapPage() { fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe) const pc = pingColor(merged.pingMs) + const greFlow = netflowByGreKey.get(tunnelPanelKey) const showMetrics = merged.pingMs != null || - (merged.dlMbps != null && merged.ulMbps != null) + (merged.dlMbps != null && merged.ulMbps != null) || + hopHasRate(greFlow) return (
@@ -2272,6 +2432,11 @@ export default function NetworkMapPage() { ↓{merged.dlMbps} ↑{merged.ulMbps} )} + {hopHasRate(greFlow) && ( + + {formatNetflowRate(greFlow)} + + )}
)} diff --git a/backend/package.json b/backend/package.json index baf66f9..cd8e5a2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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-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-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": { diff --git a/backend/src/routes/traffic-flow.ts b/backend/src/routes/traffic-flow.ts index 8bc4266..ac3e3af 100644 --- a/backend/src/routes/traffic-flow.ts +++ b/backend/src/routes/traffic-flow.ts @@ -24,6 +24,7 @@ import { listFlowExporters, safeBuildLiveFlowSample, } from "../services/traffic-flow-analytics.js" +import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js" import { applyFlowOverlay } from "../services/traffic-flow-overlay.js" import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js" import { appendEvent } from "../modules/events/service/events-service.js" @@ -222,6 +223,10 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => { return reply.send(buildFlowAnalytics(analyticsQuery(req))) }) + app.get("/traffic/flow/map-hops", async (req, reply) => { + return reply.send(buildFlowMapHops(analyticsQuery(req))) + }) + app.get("/traffic/flow/monthly", async (req, reply) => { const q = req.query as { month?: string; serverId?: string } const now = new Date() diff --git a/backend/src/services/traffic-flow-map-hops.test.ts b/backend/src/services/traffic-flow-map-hops.test.ts new file mode 100644 index 0000000..0116536 --- /dev/null +++ b/backend/src/services/traffic-flow-map-hops.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict" +import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js" +import { + ingestParsedFlowsForServerForTests, + resetFlowRingsForTests, +} from "./traffic-flow-ingest.js" +import { buildFlowMapHops } 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, +} from "./traffic-flow-ripe.js" + +disableCatalogFetchForTests() +resetFlowCatalogForTests() +disableRipePersistForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() + +const topo: FlowTopology = { + clientIfaces: new Map([[7, new Set(["gre-client"])]]), + clientByIface: new Map([["7|gre-client", { + userId: "u1", + login: "alice", + name: "Alice", + serverId: 7, + interfaceName: "gre-client", + }]]), + enNodes: [{ id: 9, name: "NSK-EN", hosts: ["198.51.100.1"] }], + enHosts: new Set(["198.51.100.1"]), + jhHosts: new Set(["203.0.113.10"]), + wanIfaces: new Map([[3, new Set(["ether1-rt"])]]), + plane: { + clientIfaceNames: new Set(["gre-client"]), + enHosts: new Set(["198.51.100.1"]), + jhHosts: new Set(["203.0.113.10"]), + }, +} + +resetFlowRingsForTests() +resetIfaceCacheForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*2", name: "gre-client" }, + { ".id": "*3", name: "gre-jh-en" }, + { ".id": "*A", name: "wg-flow" }, +]) +rememberServerIfaces(3, [ + { ".id": "*1", name: "ether1-rt" }, +]) + +ingestParsedFlowsForServerForTests(7, [ + { + src: "10.100.1.17", + dst: "8.8.8.8", + proto: 6, + srcPort: 51234, + dstPort: 443, + bytes: 12_000, + packets: 10, + inIface: "2", + outIface: "3", + nextHop: "198.51.100.1", + }, + { + src: "203.0.113.10", + dst: "198.51.100.1", + proto: 47, + srcPort: 0, + dstPort: 0, + bytes: 5_000_000, + packets: 4000, + inIface: "3", + outIface: "3", + }, + { + src: "10.100.1.17", + dst: "10.100.1.18", + proto: 6, + srcPort: 50000, + dstPort: 443, + bytes: 8000, + packets: 8, + inIface: "2", + outIface: "2", + }, + { + src: "10.255.254.1", + dst: "10.255.254.2", + proto: 17, + srcPort: 4739, + dstPort: 2055, + bytes: 400, + packets: 2, + inIface: "10", + outIface: "", + }, +]) +ingestParsedFlowsForServerForTests(3, [ + { + src: "192.168.1.10", + dst: "8.8.4.4", + proto: 6, + srcPort: 40000, + dstPort: 443, + bytes: 3000, + packets: 4, + inIface: "1", + outIface: "1", + }, +]) + +try { + const def = buildFlowMapHops({ minutes: 5 }) + assert.equal(def.excludeOverlayApplied, true) + assert.equal(def.excludeMeshApplied, true) + assert.equal(def.dedupApplied, true) + assert.equal(def.windowSec, 300) + + const payloadGre = def.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9") + assert.ok(payloadGre, "payload JH→EN hop") + assert.equal(payloadGre.bytes, 12_000) + assert.equal(payloadGre.bps, (12_000 * 8) / 300) + assert.equal(payloadGre.bpsFwd, (12_000 * 8) / 300) + assert.equal(payloadGre.iface, "gre-jh-en") + + const greIface = def.hops.find((h) => h.kind === "iface" && h.iface === "gre-jh-en" && h.fromId === "7") + assert.ok(greIface) + assert.equal(greIface.bytes, 12_000) + assert.equal(greIface.bpsFwd, (12_000 * 8) / 300) + + assert.ok(!def.hops.some((h) => h.bytes >= 5_000_000), "overlay GRE proto 47 excluded") + assert.ok(!def.hops.some((h) => h.iface === "wg-flow"), "mgmt wg-flow excluded") + const clientIngress = def.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface") + assert.ok(clientIngress, "payload ingress on client iface") + assert.equal(clientIngress.bytes, 12_000) + + const wan = def.hops.find((h) => h.kind === "wan" && h.fromId === "3" && h.iface === "ether1-rt") + assert.ok(wan, "WAN hop from home-router") + assert.equal(wan.bytes, 3000) + + 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) + const meshIface = withAll.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface") + assert.ok(meshIface && meshIface.bytes >= 20_000) +} finally { + seedFlowTopologyForTests(null) + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() + resetFlowCatalogForTests() +} + +console.log("traffic-flow-map-hops.test.ts: ok") diff --git a/backend/src/services/traffic-flow-map-hops.ts b/backend/src/services/traffic-flow-map-hops.ts new file mode 100644 index 0000000..0afc1a0 --- /dev/null +++ b/backend/src/services/traffic-flow-map-hops.ts @@ -0,0 +1,216 @@ +import { eq } from "drizzle-orm" +import type { FlowMapHop, FlowMapHopsDto } 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 { 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" + +export interface FlowMapHopsQuery { + minutes: number + serverId?: number + userId?: string + iface?: string + dedup?: boolean + excludeMesh?: boolean + excludeOverlay?: boolean +} + +interface HopAcc { + fromId: string + fromLabel: string + toId: string + toLabel: string + kind: FlowMapHop["kind"] + iface?: string + bytes: number + bytesFwd: number + bytesRev: number +} + +function userIfaceAllow(userId: string): Map> | null { + if (!userId) return null + const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all() + const allow = new Map>() + for (const b of binds) { + const set = allow.get(b.serverId) ?? new Set() + set.add(b.interfaceName) + allow.set(b.serverId, set) + } + return allow +} + +function ifaceUsable(name: string): boolean { + return Boolean(name) && name !== "—" +} + +function bump(acc: Map, key: string, seed: Omit, bytes: number, dir: "fwd" | "rev" | "both"): void { + const prev = acc.get(key) + const addFwd = dir === "fwd" || dir === "both" ? bytes : 0 + const addRev = dir === "rev" || dir === "both" ? bytes : 0 + if (prev) { + prev.bytes += bytes + prev.bytesFwd += addFwd + prev.bytesRev += addRev + if (seed.iface && !prev.iface) prev.iface = seed.iface + return + } + acc.set(key, { + ...seed, + bytes, + bytesFwd: addFwd, + bytesRev: addRev, + }) +} + +function toHop(a: HopAcc, windowSec: number): FlowMapHop { + return { + fromId: a.fromId, + fromLabel: a.fromLabel, + toId: a.toId, + toLabel: a.toLabel, + kind: a.kind, + ...(a.iface ? { iface: a.iface } : {}), + bytes: a.bytes, + bps: (a.bytes * 8) / windowSec, + bpsFwd: (a.bytesFwd * 8) / windowSec, + bpsRev: (a.bytesRev * 8) / windowSec, + } +} + +/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */ +export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto { + const windowSec = Math.max(60, q.minutes * 60) + const raw = listFlowRowsForWindow(q.minutes) + const allow = q.userId ? userIfaceAllow(q.userId) : null + const serverRows = db.select().from(servers).all() + const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host])) + const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : "" + const wantDedup = q.dedup !== false && !ifaceFilter + const excludeMesh = q.excludeMesh !== false + const excludeOverlay = q.excludeOverlay !== false + const topo = loadFlowTopology() + + const matched = [] + for (const r of raw) { + const resolved = resolveIfaceName(r.serverId, r.inIface) + const outResolved = resolveIfaceName(r.serverId, r.outIface) + if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue + const plane = classifyFlowPlane({ + src: r.src, + dst: r.dst, + proto: r.proto, + srcPort: r.srcPort, + dstPort: r.dstPort, + inIface: resolved.name, + outIface: outResolved.name, + }, topo.plane) + if (!shouldKeepPlane(plane, { excludeMesh, excludeOverlay })) continue + matched.push(r) + } + + const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched + const hops = new Map() + + for (const r of working) { + const inRes = resolveIfaceName(r.serverId, r.inIface) + const outRes = resolveIfaceName(r.serverId, r.outIface) + const inName = inRes.name + const outName = outRes.name + const fromId = String(r.serverId) + const fromLabel = nameById.get(r.serverId) ?? fromId + const wanSet = topo.wanIfaces.get(r.serverId) + + const inOk = ifaceUsable(inName) + const outOk = ifaceUsable(outName) + const sameIface = inOk && outOk && inName.toLowerCase() === outName.toLowerCase() + if (sameIface) { + bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, { + fromId, + fromLabel, + toId: "", + toLabel: "", + kind: "iface", + iface: inName, + }, r.bytes, "fwd") + } else { + if (inOk) { + bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, { + fromId, + fromLabel, + toId: "", + toLabel: "", + kind: "iface", + iface: inName, + }, r.bytes, "rev") + } + if (outOk) { + bump(hops, `iface|${fromId}|${outName.toLowerCase()}`, { + fromId, + fromLabel, + toId: "", + toLabel: "", + kind: "iface", + iface: outName, + }, r.bytes, "fwd") + } + } + + const enOut = ifaceUsable(outName) ? resolveEn(topo, r.nextHop, outName) : null + const enIn = ifaceUsable(inName) ? resolveEn(topo, "", inName) : null + const en = (enOut && enOut.id !== r.serverId ? enOut : null) + ?? (enIn && enIn.id !== r.serverId ? enIn : null) + if (en) { + 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) + bump(hops, `gre|${fromId}|${toId}`, { + fromId, + fromLabel, + toId, + toLabel: en.name, + kind: "gre", + iface: greIface, + }, r.bytes, dir) + } + + if (wanSet?.size) { + if (ifaceUsable(inName) && wanSet.has(inName)) { + bump(hops, `wan|${fromId}|${inName.toLowerCase()}`, { + fromId, + fromLabel, + toId: "", + toLabel: "", + kind: "wan", + iface: inName, + }, r.bytes, "rev") + } + if (ifaceUsable(outName) && wanSet.has(outName) && outName.toLowerCase() !== inName.toLowerCase()) { + bump(hops, `wan|${fromId}|${outName.toLowerCase()}`, { + fromId, + fromLabel, + toId: "", + toLabel: "", + kind: "wan", + iface: outName, + }, r.bytes, "fwd") + } + } + } + + const listener = getFlowListenerState() + return { + hops: [...hops.values()] + .map((a) => toHop(a, windowSec)) + .sort((a, b) => b.bytes - a.bytes), + live: listener.bound, + rangeMinutes: q.minutes, + windowSec, + dedupApplied: wantDedup, + excludeMeshApplied: excludeMesh, + excludeOverlayApplied: excludeOverlay, + } +} diff --git a/lib/map-netflow-hops.ts b/lib/map-netflow-hops.ts new file mode 100644 index 0000000..b99166d --- /dev/null +++ b/lib/map-netflow-hops.ts @@ -0,0 +1,103 @@ +import type { FlowMapHop } from "@mmapp/contracts/traffic-flow" +import { fmtRate } from "@/lib/fmt-rate" + +export interface MatchedNetflowHop { + bps: number + bpsFwd: number + bpsRev: number + bytes: number +} + +function ifaceNorm(s: string | undefined): string { + return (s ?? "").trim().toLowerCase() +} + +function pairKey(a: string, b: string): string { + const x = String(a) + const y = String(b) + return x <= y ? `${x}\t${y}` : `${y}\t${x}` +} + +function mergeDirected(hops: FlowMapHop[], mapFromId: string): MatchedNetflowHop { + let bytes = 0 + let bpsFwd = 0 + let bpsRev = 0 + const from = String(mapFromId) + for (const h of hops) { + bytes += h.bytes + if (h.fromId === from) { + bpsFwd += h.bpsFwd + bpsRev += h.bpsRev + } else { + bpsFwd += h.bpsRev + bpsRev += h.bpsFwd + } + } + return { bytes, bpsFwd, bpsRev, bps: bpsFwd + bpsRev } +} + +export function hopHasRate(h: MatchedNetflowHop | undefined): h is MatchedNetflowHop { + return h != null && Number.isFinite(h.bps) && h.bps > 0 +} + +export function formatNetflowRate(hop: MatchedNetflowHop): string { + return fmtRate(hop.bps / 1_000_000) +} + +export function formatNetflowDir(hop: MatchedNetflowHop): string { + return `↓${fmtRate(hop.bpsFwd / 1_000_000)} ↑${fmtRate(hop.bpsRev / 1_000_000)}` +} + +/** GRE: сначала имя интерфейса туннеля на любом конце, иначе пара узлов. */ +export function matchNetflowForGreEdge( + edge: { + tunnel: { name: string } + fromServer: { id: string } + toServer: { id: string } + }, + hops: FlowMapHop[], +): MatchedNetflowHop | undefined { + const name = ifaceNorm(edge.tunnel.name) + const fromId = String(edge.fromServer.id) + const toId = String(edge.toServer.id) + if (name) { + const ifaceHits = hops.filter((h) => + h.kind === "iface" + && ifaceNorm(h.iface) === name + && (h.fromId === fromId || h.fromId === toId), + ) + if (ifaceHits.length) return mergeDirected(ifaceHits, fromId) + const greNamed = hops.filter((h) => + h.kind === "gre" + && ifaceNorm(h.iface) === name + && (h.fromId === fromId || h.fromId === toId || h.toId === fromId || h.toId === toId), + ) + if (greNamed.length) return mergeDirected(greNamed, fromId) + } + const want = pairKey(fromId, toId) + const pairHits = hops.filter((h) => + h.kind === "gre" && Boolean(h.toId) && pairKey(h.fromId, h.toId) === want, + ) + if (pairHits.length) return mergeDirected(pairHits, fromId) + return undefined +} + +/** WAN-аплинк HR: kind wan, иначе iface с тем же именем на homeId. */ +export function matchNetflowForWan( + homeId: string, + wanIface: string, + hops: FlowMapHop[], +): MatchedNetflowHop | undefined { + const id = String(homeId) + const iface = ifaceNorm(wanIface) + if (!iface) return undefined + const wanHits = hops.filter((h) => + h.kind === "wan" && h.fromId === id && ifaceNorm(h.iface) === iface, + ) + if (wanHits.length) return mergeDirected(wanHits, id) + const ifaceHits = hops.filter((h) => + h.kind === "iface" && h.fromId === id && ifaceNorm(h.iface) === iface, + ) + if (ifaceHits.length) return mergeDirected(ifaceHits, id) + return undefined +} diff --git a/packages/contracts/src/traffic-flow.ts b/packages/contracts/src/traffic-flow.ts index fb778d9..9b5ef14 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -248,6 +248,31 @@ export const flowPurgeDtoSchema = z.object({ vacuumed: z.boolean(), }) +export const flowMapHopKindSchema = z.enum(["gre", "wan", "iface"]) + +export const flowMapHopDtoSchema = z.object({ + fromId: z.string(), + fromLabel: z.string(), + toId: z.string(), + toLabel: z.string(), + kind: flowMapHopKindSchema, + iface: z.string().optional(), + bytes: z.number().nonnegative(), + bps: z.number().nonnegative(), + bpsFwd: z.number().nonnegative(), + bpsRev: z.number().nonnegative(), +}) + +export const flowMapHopsDtoSchema = z.object({ + hops: z.array(flowMapHopDtoSchema), + live: z.boolean(), + rangeMinutes: z.number().int().positive(), + windowSec: z.number().positive(), + dedupApplied: z.boolean(), + excludeMeshApplied: z.boolean(), + excludeOverlayApplied: z.boolean(), +}) + export type FlowTalkerDto = z.infer export type FlowStatsDto = z.infer export type FlowBreakdownRow = z.infer @@ -260,3 +285,6 @@ export type FlowExportersDto = z.infer export type FlowClientsDto = z.infer export type FlowMonthlyDto = z.infer export type FlowPurgeDto = z.infer +export type FlowMapHopKind = z.infer +export type FlowMapHop = z.infer +export type FlowMapHopsDto = z.infer diff --git a/shared/api/traffic-flow.ts b/shared/api/traffic-flow.ts index 2f733fb..45cf2ff 100644 --- a/shared/api/traffic-flow.ts +++ b/shared/api/traffic-flow.ts @@ -2,6 +2,7 @@ import type { FlowAnalyticsDto, FlowClientsDto, FlowExportersDto, + FlowMapHopsDto, FlowMonthlyDto, FlowPurgeDto, FlowStatsDto, @@ -88,6 +89,29 @@ export async function getFlowClients(baseUrl: string, range = "5m"): Promise(baseUrl, `/api/traffic/flow/clients?range=${encodeURIComponent(range)}`) } +export async function getFlowMapHops( + baseUrl: string, + params: { + range?: string + serverId?: string + userId?: string + iface?: string + dedup?: boolean + excludeMesh?: boolean + excludeOverlay?: boolean + } = {}, +): Promise { + return requestJson(baseUrl, `/api/traffic/flow/map-hops${flowQuery({ + range: params.range ?? "5m", + serverId: params.serverId, + userId: params.userId, + iface: params.iface, + dedup: params.dedup, + excludeMesh: params.excludeMesh, + excludeOverlay: params.excludeOverlay, + })}`) +} + export async function getFlowAnalytics( baseUrl: string, params: {