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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user