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,
|
wanJhEdgeMapKey,
|
||||||
type GreSpeedProbeSnapshot,
|
type GreSpeedProbeSnapshot,
|
||||||
} from "@/lib/map-gre-speed-probe"
|
} 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 { Button } from "@/components/ui/button"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
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 } }) {
|
function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
||||||
const ss = STATUS_STYLE[n.status]
|
const ss = STATUS_STYLE[n.status]
|
||||||
const ts = TYPE_STYLE[n.type]
|
const ts = TYPE_STYLE[n.type]
|
||||||
@@ -784,6 +840,7 @@ export default function NetworkMapPage() {
|
|||||||
const [mapServers, setMapServers] = useState<Server[]>([])
|
const [mapServers, setMapServers] = useState<Server[]>([])
|
||||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||||||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||||||
|
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
|
||||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||||
const [dataError, setDataError] = useState<string | null>(null)
|
const [dataError, setDataError] = useState<string | null>(null)
|
||||||
@@ -935,11 +992,31 @@ export default function NetworkMapPage() {
|
|||||||
const [filter, setFilter] = useState<FilterKey>("all")
|
const [filter, setFilter] = useState<FilterKey>("all")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [showPingBadges, setShowPingBadges] = useState(true)
|
const [showPingBadges, setShowPingBadges] = useState(true)
|
||||||
|
const [showNetflow, setShowNetflow] = useState(true)
|
||||||
const [showAnimDots, setShowAnimDots] = useState(true)
|
const [showAnimDots, setShowAnimDots] = useState(true)
|
||||||
const [showMinimap, setShowMinimap] = useState(true)
|
const [showMinimap, setShowMinimap] = useState(true)
|
||||||
const [showHints, setShowHints] = useState(false)
|
const [showHints, setShowHints] = useState(false)
|
||||||
const [showLayers, setShowLayers] = 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 effectiveSatPos = useMemo(() => {
|
||||||
const out: Record<string, { x: number; y: number }[]> = {}
|
const out: Record<string, { x: number; y: number }[]> = {}
|
||||||
mapServers
|
mapServers
|
||||||
@@ -1100,6 +1177,32 @@ export default function NetworkMapPage() {
|
|||||||
return out
|
return out
|
||||||
}, [greEdges])
|
}, [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
|
const nodes = mapServers
|
||||||
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
|
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
|
||||||
// Визуальный приоритет: HR поверх JH, JH поверх EN.
|
// Визуальный приоритет: HR поверх JH, JH поверх EN.
|
||||||
@@ -1461,6 +1564,7 @@ export default function NetworkMapPage() {
|
|||||||
onMouseLeave={() => setShowLayers(false)}>
|
onMouseLeave={() => setShowLayers(false)}>
|
||||||
{([
|
{([
|
||||||
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
|
{ 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: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
|
||||||
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
|
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
|
||||||
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
|
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
|
||||||
@@ -1588,6 +1692,15 @@ export default function NetworkMapPage() {
|
|||||||
tBadge,
|
tBadge,
|
||||||
normalPx,
|
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>) {
|
function openGreDetail(ev: React.MouseEvent<SVGElement>) {
|
||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
setSelectedGreEdge(e)
|
setSelectedGreEdge(e)
|
||||||
@@ -1598,7 +1711,7 @@ export default function NetworkMapPage() {
|
|||||||
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
|
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
|
||||||
<line
|
<line
|
||||||
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
|
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"}
|
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
|
||||||
opacity={ts.opacity}
|
opacity={ts.opacity}
|
||||||
/>
|
/>
|
||||||
@@ -1632,6 +1745,14 @@ export default function NetworkMapPage() {
|
|||||||
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
|
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showNetflow && hopHasRate(flowHop) && (
|
||||||
|
<NetflowRateBadge
|
||||||
|
mx={flowPos.mx}
|
||||||
|
my={flowPos.my}
|
||||||
|
hop={flowHop}
|
||||||
|
onOpen={openGreDetail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1663,14 +1784,16 @@ export default function NetworkMapPage() {
|
|||||||
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
|
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
|
||||||
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
|
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 { 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 isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
|
||||||
|
const wanFlow = netflowByWanKey.get(wanJhEdgeMapKey(edge))
|
||||||
return (
|
return (
|
||||||
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
|
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
|
||||||
style={{ transition: "opacity 0.3s" }}>
|
style={{ transition: "opacity 0.3s" }}>
|
||||||
<line
|
<line
|
||||||
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
|
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={edge.active ? 2 : 1.2}
|
strokeWidth={edge.active ? (hopHasRate(wanFlow) ? 2.8 : 2) : 1.2}
|
||||||
strokeDasharray={edge.active ? "none" : "5 4"}
|
strokeDasharray={edge.active ? "none" : "5 4"}
|
||||||
opacity={edge.active ? 0.7 : 0.4}
|
opacity={edge.active ? 0.7 : 0.4}
|
||||||
filter={isHL ? `url(#glow-wan-${edge.wanIdx})` : undefined}
|
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)} />
|
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>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1976,7 +2107,7 @@ export default function NetworkMapPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{bwMon ? "TX / RX (BT)" : "Скорость (модель)"}
|
{bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-mono font-semibold text-sky-400">
|
<span className="text-xs font-mono font-semibold text-sky-400">
|
||||||
{merged.dlMbps != null && merged.ulMbps != null
|
{merged.dlMbps != null && merged.ulMbps != null
|
||||||
@@ -1984,10 +2115,23 @@ export default function NetworkMapPage() {
|
|||||||
: "—"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
|
||||||
{merged.hasSpeedMonitor
|
{merged.hasSpeedMonitor
|
||||||
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам."
|
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. "
|
||||||
: "«Модель RTT» и «скорость» — демо до появления подходящей speed-пробы в «Мониторинг → скорость»."}
|
: "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "}
|
||||||
|
Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2168,12 +2312,26 @@ export default function NetworkMapPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip],
|
{[["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">
|
<div key={k} className="flex items-center justify-between">
|
||||||
<span className="text-[10px] text-muted-foreground">{k}</span>
|
<span className="text-[10px] text-muted-foreground">{k}</span>
|
||||||
<span className="text-[10px] font-mono">{v}</span>
|
<span className="text-[10px] font-mono">{v}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{myEdges.length > 0 && (
|
{myEdges.length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-border/40">
|
<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
|
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
|
||||||
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
||||||
const pc = pingColor(merged.pingMs)
|
const pc = pingColor(merged.pingMs)
|
||||||
|
const greFlow = netflowByGreKey.get(tunnelPanelKey)
|
||||||
const showMetrics =
|
const showMetrics =
|
||||||
merged.pingMs != null ||
|
merged.pingMs != null ||
|
||||||
(merged.dlMbps != null && merged.ulMbps != null)
|
(merged.dlMbps != null && merged.ulMbps != null) ||
|
||||||
|
hopHasRate(greFlow)
|
||||||
return (
|
return (
|
||||||
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
|
<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">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -2272,6 +2432,11 @@ export default function NetworkMapPage() {
|
|||||||
↓{merged.dlMbps} ↑{merged.ulMbps}
|
↓{merged.dlMbps} ↑{merged.ulMbps}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{hopHasRate(greFlow) && (
|
||||||
|
<span className="text-[9px] ml-1.5 text-emerald-400">
|
||||||
|
{formatNetflowRate(greFlow)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
"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:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||||
"test:traffic-rate": "tsx src/services/traffic-rate.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"
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
listFlowExporters,
|
listFlowExporters,
|
||||||
safeBuildLiveFlowSample,
|
safeBuildLiveFlowSample,
|
||||||
} from "../services/traffic-flow-analytics.js"
|
} from "../services/traffic-flow-analytics.js"
|
||||||
|
import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js"
|
||||||
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||||
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
||||||
import { appendEvent } from "../modules/events/service/events-service.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)))
|
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) => {
|
app.get("/traffic/flow/monthly", async (req, reply) => {
|
||||||
const q = req.query as { month?: string; serverId?: string }
|
const q = req.query as { month?: string; serverId?: string }
|
||||||
const now = new Date()
|
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(),
|
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 FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
||||||
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
||||||
export type FlowBreakdownRow = z.infer<typeof flowBreakdownRowSchema>
|
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 FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
|
||||||
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
|
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
|
||||||
export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema>
|
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,
|
FlowAnalyticsDto,
|
||||||
FlowClientsDto,
|
FlowClientsDto,
|
||||||
FlowExportersDto,
|
FlowExportersDto,
|
||||||
|
FlowMapHopsDto,
|
||||||
FlowMonthlyDto,
|
FlowMonthlyDto,
|
||||||
FlowPurgeDto,
|
FlowPurgeDto,
|
||||||
FlowStatsDto,
|
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)}`)
|
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(
|
export async function getFlowAnalytics(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user