Compare commits

..
1 Commits
Author SHA1 Message Date
DenozordecandCursor 6332d83a12 feat(traffic): показать поток NetFlow на карте сети
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 2m14s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 11s
Скорость между узлами считается как в Трафике (5 мин, без overlay/mesh), отдельно от ёмкости WAN и BT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 12:37:47 +07:00
8 changed files with 706 additions and 8 deletions
+172 -7
View File
@@ -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<SVGElement>) => void
}) {
const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0
const bw = showDir ? 86 : 72
const bh = showDir ? 32 : 20
return (
<g
transform={`translate(${mx},${my})`}
style={{ cursor: onOpen ? "pointer" : "default" }}
onPointerDown={(e) => { e.stopPropagation() }}
onClick={(e) => { e.stopPropagation(); onOpen?.(e) }}
>
<title>
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала отдельно.
</title>
<rect
x={-bw / 2}
y={-bh / 2}
width={bw}
height={bh}
rx="6"
fill="rgba(6,13,26,0.94)"
stroke="#34d399"
strokeWidth="1.15"
/>
<text textAnchor="middle" y={showDir ? "-4" : "4"} fontFamily="ui-monospace,monospace">
<tspan fill="#6ee7b7" fontSize="8" fontWeight="700">{formatNetflowRate(hop)}</tspan>
</text>
{showDir && (
<text textAnchor="middle" y="10" fontFamily="ui-monospace,monospace">
<tspan fill="#34d399" fontSize="6.5" fontWeight="600">{formatNetflowDir(hop)}</tspan>
</text>
)}
</g>
)
}
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<Server[]>([])
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
@@ -935,11 +992,31 @@ export default function NetworkMapPage() {
const [filter, setFilter] = useState<FilterKey>("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<FlowMapHopsDto>("/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<string, { x: number; y: number }[]> = {}
mapServers
@@ -1100,6 +1177,32 @@ export default function NetworkMapPage() {
return out
}, [greEdges])
const netflowByGreKey = useMemo(() => {
const m = new Map<string, MatchedNetflowHop>()
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<string, MatchedNetflowHop>()
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<SVGElement>) {
ev.stopPropagation()
setSelectedGreEdge(e)
@@ -1598,7 +1711,7 @@ export default function NetworkMapPage() {
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
<line
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke={ts.stroke} strokeWidth="1.5"
stroke={ts.stroke} strokeWidth={hopHasRate(flowHop) ? 2.6 : 1.5}
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
opacity={ts.opacity}
/>
@@ -1632,6 +1745,14 @@ export default function NetworkMapPage() {
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
/>
)}
{showNetflow && hopHasRate(flowHop) && (
<NetflowRateBadge
mx={flowPos.mx}
my={flowPos.my}
hop={flowHop}
onOpen={openGreDetail}
/>
)}
</g>
)
})}
@@ -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 (
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
style={{ transition: "opacity 0.3s" }}>
<line
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
stroke={color}
strokeWidth={edge.active ? 2 : 1.2}
strokeWidth={edge.active ? (hopHasRate(wanFlow) ? 2.8 : 2) : 1.2}
strokeDasharray={edge.active ? "none" : "5 4"}
opacity={edge.active ? 0.7 : 0.4}
filter={isHL ? `url(#glow-wan-${edge.wanIdx})` : undefined}
@@ -1710,6 +1833,14 @@ export default function NetworkMapPage() {
}
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
})()}
{showNetflow && hopHasRate(wanFlow) && (
<NetflowRateBadge
mx={flowPos.mx}
my={flowPos.my}
hop={wanFlow}
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
/>
)}
</g>
)
})}
@@ -1976,7 +2107,7 @@ export default function NetworkMapPage() {
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">
{bwMon ? "TX / RX (BT)" : "Скорость (модель)"}
{bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"}
</span>
<span className="text-xs font-mono font-semibold text-sky-400">
{merged.dlMbps != null && merged.ulMbps != null
@@ -1984,10 +2115,23 @@ export default function NetworkMapPage() {
: "—"}
</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Поток (NetFlow)</span>
<span className="text-xs font-mono font-semibold text-emerald-400">
{(() => {
const hop = netflowByGreKey.get(selectedEdgeId)
if (!hopHasRate(hop)) return "—"
return hop.bpsFwd > 0 && hop.bpsRev > 0
? formatNetflowDir(hop)
: formatNetflowRate(hop)
})()}
</span>
</div>
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
{merged.hasSpeedMonitor
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам."
: "«Модель RTT» и «скорость» — демо до появления подходящей speed-пробы в «Мониторинг → скорость»."}
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. "
: "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "}
Поток живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
</p>
</div>
</div>
@@ -2168,12 +2312,26 @@ export default function NetworkMapPage() {
</div>
<div className="flex flex-col gap-1">
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip],
["BW", `${wan.maxDl}${wan.maxUl} Мбит`]].map(([k, v]) => (
["Канал", `${wan.maxDl}${wan.maxUl} Мбит`]].map(([k, v]) => (
<div key={k} className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{k}</span>
<span className="text-[10px] font-mono">{v}</span>
</div>
))}
{(() => {
const hop = netflowByWanKey.get(`${selected.id}\t${wIdx}`)
if (!hopHasRate(hop)) return null
return (
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Поток</span>
<span className="text-[10px] font-mono text-emerald-400">
{hop.bpsFwd > 0 && hop.bpsRev > 0
? formatNetflowDir(hop)
: formatNetflowRate(hop)}
</span>
</div>
)
})()}
</div>
{myEdges.length > 0 && (
<div className="mt-2 pt-2 border-t border-border/40">
@@ -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 (
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
<div className="flex items-center justify-between mb-1">
@@ -2272,6 +2432,11 @@ export default function NetworkMapPage() {
{merged.dlMbps} {merged.ulMbps}
</span>
)}
{hopHasRate(greFlow) && (
<span className="text-[9px] ml-1.5 text-emerald-400">
{formatNetflowRate(greFlow)}
</span>
)}
</span>
</div>
)}
+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-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": {
+5
View File
@@ -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()
@@ -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")
@@ -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<number, Set<string>> | null {
if (!userId) return null
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
const allow = new Map<number, Set<string>>()
for (const b of binds) {
const set = allow.get(b.serverId) ?? new Set<string>()
set.add(b.interfaceName)
allow.set(b.serverId, set)
}
return allow
}
function ifaceUsable(name: string): boolean {
return Boolean(name) && name !== "—"
}
function bump(acc: Map<string, HopAcc>, key: string, seed: Omit<HopAcc, "bytes" | "bytesFwd" | "bytesRev">, 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<string, HopAcc>()
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,
}
}
+103
View File
@@ -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
}
+28
View File
@@ -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<typeof flowTalkerDtoSchema>
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
export type FlowBreakdownRow = z.infer<typeof flowBreakdownRowSchema>
@@ -260,3 +285,6 @@ export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema>
export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
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 FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
+24
View File
@@ -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<Flo
return requestJson<FlowClientsDto>(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<FlowMapHopsDto> {
return requestJson<FlowMapHopsDto>(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: {