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
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 <[email protected]>
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user